Introduction
This post documents my learning process regarding building an HTTP server in Go. Since I am still fairly new to Go, this post contains not only concepts about HTTP servers, but also some Go coding styles and practices that I discovered along the way.
Server
Defining a basic server in Go looks like this:
12345678910package mainimport "net/http"
func main(){ server := &http.Server{ Addr: ":8080", }
server.ListenAndServe()}The code is simple enough, feeling very similar to setting up a server with Express.js in JavaScript. However, since I’m new to Go, I wanted to dive into why we write it this way—specifically, that & ampersand.
In the official Go documentation(opens in a new tab), the examples always use the & symbol before http.Server{}. Interestingly, if I remove the ampersand, the compiler doesn't throw an error. This is because Go is smart enough to handle the conversion, but the underlying mechanics are important.
If you hover over server.ListenAndServe(), you’ll see the function signature requires a pointer receiver: func (s *Server) ListenAndServe() error.
We use a pointer here for two main reasons:
- Efficiency: A server struct is large. You generally only need one instance, and you don't want to pass around heavy copies of it throughout your program.
- Safety: The
http.Serverstruct contains atomic fields (likeinShutdown) to prevent race conditions. Go’ssync/atomictypes must not be copied after first use. If you copy the server, you risk breaking the synchronization logic that keeps the server's state consistent.
Style Tip: &Server{} vs. new(Server)
If you come from a Java or C# background, your instinct might be to use the new keyword. In Go, you can do that, but you'll rarely see it in production code.
123// Both of these create a pointer to a zero-valued Serverserver1 := new(http.Server)server2 := &http.Server{}While they are functionally identical, the Go community almost exclusively prefers the composite literal (&T{}) for two reasons:
- Consistency: The
&T{}syntax allows you to initialize fields immediately.new(T)only allows for zero-initialization, forcing you to use multiple lines to set up your struct. - Visual Clarity: The
&symbol is a loud, clear signal that "this is a pointer." In a language where memory address vs. value matters, having that explicit symbol right at the moment of creation is preferred.
The Rule of Thumb: Use &T{} for everything. Use new(T) only if you want to emphasize that you are creating an empty, zero-initialized value.
A Note on Memory: Escape Analysis
Since we are using &http.Server, we are dealing with pointers. In many languages, using a pointer (or the new keyword) explicitly tells the computer to put that data on the Heap.
In Go, the compiler is more intelligent. It uses a process called Escape Analysis. During compilation, Go looks at your code and asks: "Does this variable 'escape' the function it was created in?"
- The Stack: If the variable is only used inside the function, Go keeps it on the Stack. This is incredibly fast because the memory is reclaimed the moment the function finishes.
- The Heap: If the variable is returned from the function or shared with other parts of the program (like our server being passed around), it "escapes" to the Heap.
The beauty of Go is that you don't have to manually decide between Stack and Heap like you do in C or C++. You simply write your code naturally. If you use the & operator, the compiler decides the most efficient place to put it.
Why should we care? While we don't "control" it, we should be aware that "Heap = Work." Anything on the heap must eventually be cleaned up by the Garbage Collector (GC). If we put too many things on the heap unnecessarily, our program might slow down while the GC works. By letting Go manage this, we get the safety of a managed language with performance that rivals lower-level languages.
The Dangers of the DefaultServeMux
In Go, a ServeMux is an HTTP request multiplexer. Its job is to match the URL of an incoming request against a list of registered patterns and call the appropriate handler. It also handles path sanitization and pattern precedence.
Go provides a DefaultServeMux out of the box, but you should almost never use it.
The DefaultServeMux is a global variable stored in the net/http package. When you call package-level functions like http.HandleFunc("/", ...), you are modifying this global instance.
123456789101112func main() { server := &http.Server{ Addr: ":8080", }
// This modifies the GLOBAL mux! http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Hello from the DEFAULT mux!") })
server.ListenAndServe()}The "Malicious Library" Scenario
The danger of a global variable is that anyone can change it. In Go, the init() function runs automatically when a package is imported. If you import a third-party library—even if you don't use any of its functions—that library can secretly register routes to your server.
Imagine a malicious "analytics" package:
12345678910111213package analytics
import ( "fmt" "net/http")
func init() { // This registers to the GLOBAL mux automatically on import! http.HandleFunc("/malicious", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "I have poisoned the global DefaultServeMux!") })}If you import this package using the "blank import" (_), the library's init() function runs, and suddenly your server is hosting a route you didn't authorize:
12345678910import ( "net/http" _ "testproject/analytics" // Secretly runs analytics.init())
func main() { server := &http.Server{Addr: ":8080"} server.ListenAndServe() // localhost:8080/malicious is now active!}The Solution: Explicit Muxing
To prevent this, you should always create your own local ServeMux. By passing your custom mux into the http.Server struct, you ensure the server only knows about the routes you explicitly defined.
1234567891011121314151617func main() { // 1. Create a private multiplexer mux := http.NewServeMux()
// 2. Register handlers to your private mux mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { fmt.Fprint(w, "Hello from a safe, private mux!") })
// 3. Inject the mux into the server server := &http.Server{ Addr: ":8080", Handler: mux, }
server.ListenAndServe()}Now, even if a library tries to poison the DefaultServeMux, your server is shielded because it isn't using the global instance.
Serving Static Files Safely
If you are building a web server that needs to serve HTML, CSS, or images, Go provides a built-in http.FileServer. This handler takes an incoming HTTP request and maps it to the file system.
The "Development" Trap
During early development, it’s tempting to serve everything from your root directory:
123456789101112func main() { mux := http.NewServeMux()
// DANGER: Serving the root directory mux.Handle("/", http.FileServer(http.Dir(".")))
server := &http.Server{ Addr: ":8081", Handler: mux, } server.ListenAndServe()}This works for seeing your index.html, but it creates a massive security flaw. A malicious user could visit localhost:8081/main.go or localhost:8081/go.mod and read your entire backend source code.
The Secure Approach: Isolation and StripPrefix
To secure the server, you must isolate your public assets into a dedicated folder like ./public and use http.StripPrefix. The http.StripPrefix function is necessary because of how the FileServer looks for files.
If a user visits localhost:3000:/app, the FileServer will try to find a file at ./public/app/index.html. Since the /app folder doesn't exist inside /public, it would return a 404. We need to strip the /app/ prefix so the FileServer only sees index.html.
123456789101112131415161718func main() { mux := http.NewServeMux()
// 1. Create a handler that only looks inside the "public" folder fsHandler := http.FileServer(http.Dir("./public"))
// 2. Use StripPrefix to remove "/app/" from the request URL // before it reaches the file server. mux.Handle("/app/", http.StripPrefix("/app/", fsHandler))
server := &http.Server{ Addr: ":8081", Handler: mux, }
fmt.Println("Server starting on :8081") server.ListenAndServe()}With this setup, the visitor is "jailed" within the ./public folder. They can access yoursite.com/app/, but they can never move "up" the directory tree to access your private source code or environment variables.
Stateful Handlers: Moving Beyond Global Variables
A classic "Hello World" in Go looks like this:
12345mux.HandleFunc("/app/hello", func(w http.ResponseWriter, r *http.Request) { w.Header().Add("Content-Type", "text/plain; charset=utf-8") w.WriteHeader(http.StatusOK) w.Write([]byte("Hello World"))})But what if we want to track state—like a counter that increments every time a user visits? Your first instinct might be to use a global variable:
123456789var globalHits atomic.Int32
func main() { // ... mux.HandleFunc("/app/hits", func(w http.ResponseWriter, r *http.Request) { globalHits.Add(1) fmt.Println(globalHits.Load()) })}While this works, it’s a maintenance nightmare. As your app grows, you’ll have database connections, platform settings, and multiple counters. Scattering these as globals makes your code hard to test and even harder to read.
The "Shared Brain": The apiConfig Struct
The idiomatic Go solution is to bundle everything related to your app’s state into a single struct. We then turn our handlers into methods of that struct using a "pointer receiver."
1234567891011type apiConfig struct { fileserverHits atomic.Int32 query *database.Queries}
// This method is "bound" to our config structfunc (cfg *apiConfig) handlerMetrics(w http.ResponseWriter, r *http.Request) { cfg.fileserverHits.Add(1) w.WriteHeader(http.StatusOK) w.Write([]byte(fmt.Sprintf("Hits: %d", cfg.fileserverHits.Load())))}By doing this, we achieve Dependency Injection. Any function attached to apiConfig immediately knows how to talk to the database or increment the hit counter without needing global variables.
Middleware: The "Handler Factory"
A standalone handler is great, but what if you want to count hits on every request—including requests for static files? You don’t want to manually add cfg.fileserverHits.Add(1) to every single route. This is where Middleware comes in.
Think of middleware as a factory. It takes an existing handler, wraps it in a "costume," and returns a new handler. The signature looks like this: func(next http.Handler) http.Handler.
Building the Metric Middleware
Here is how we turn our hit counter into a bridge that sits between the server and the final destination:
12345678func (cfg *apiConfig) middlewareMetricsInc(next http.Handler) http.Handler { // We return a "HandlerFunc" which is a type cast that // gives our anonymous function the required ServeHTTP method. return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { cfg.fileserverHits.Add(1) // 1. Do our work (increment counter) next.ServeHTTP(w, r) // 2. Pass the baton to the next handler })}The "Domino Effect" in Reality
It is important to remember that the Go server itself has no idea what "middleware" is. When we write this line:
mux.Handle("/app/", cfg.middlewareMetricsInc(fsHandler))
The function middlewareMetricsInc runs immediately (at startup), creates an anonymous function that "captures" the fsHandler, and hands that anonymous function to the ServeMux. When a request actually arrives, here is the real sequence:
- The Server calls the Product: The server executes the
ServeHTTPmethod of the anonymous function created by our factory. - The Work Happens: Inside that anonymous function, our code increments the counter:
cfg.fileserverHits.Add(1). - The Baton Pass: The anonymous function then calls
next.ServeHTTP(w, r). Because it "captured" thefsHandlerearlier, this call triggers the FileServer's logic.
This is why we call it a chain. Each handler is responsible for calling the next one. If our anonymous function forgot to call next.ServeHTTP, the request would simply stop there—the "domino" effect would be broken, and the FileServer would never run.
Database Infrastructure
Docker
For development, I use Docker to spin up a PostgreSQL instance. While my Go application performs a db.Ping() at startup, I also implemented a Docker-level healthcheck using pg_isready.
This provides a higher level of infrastructure reliability. It prevents the "startup race condition" where the Go app tries to connect before the database is actually ready to accept connections. By adding this check, Docker ensures the database service is "Healthy" before dependent services attempt to communicate with it.
1234567891011121314151617181920services: db: image: postgres:18-alpine container_name: testserver-db environment: - POSTGRES_USER=postgres - POSTGRES_PASSWORD=password - POSTGRES_DB=test_chirpy ports: - "5432:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d test_chirpy"] interval: 10s timeout: 5s retries: 5 volumes: - pgdata:/var/lib/postgresql/data
volumes: pgdata:Tooling: Goose and SQLC
In Go, it is standard practice to separate build-time tools from runtime dependencies. To keep my application binaries clean—containing only the standard library database/sql and the necessary driver lib/pq—I use two specific tools:
- Goose(opens in a new tab): For version-controlled database migrations.
- SQLC(opens in a new tab): To generate type-safe Go code from raw SQL.
This setup prevents "ORM bloat" and gives us the performance of raw SQL with the safety of a compiled language.
Configuration for sqlc lives in sqlc.yaml, telling it where to find my queries and where to output the Go code:
123456789version: "2"sql: - schema: "sql/schema" queries: "sql/queries" engine: "postgresql" gen: go: package: "database" out: "internal/database"Migrations
We define our schema in sql/schema. Standard convention is to number files sequentially like 001_users.sql so the tool knows the order of execution.
1234567891011-- +goose UpCREATE TABLE users ( id UUID PRIMARY KEY, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, email TEXT UNIQUE NOT NULL, hashed_password TEXT NOT NULL DEFAULT 'unset');
-- +goose DownDROP TABLE users;The annotations -- +goose Up and -- +goose Down are directives for the tool.
- Up: Defines how to apply the change.
- Down: Defines how to undo the change.
In a professional environment, the "Down" migration is critical for rolling back changes if a deployment fails. When we run goose postgres "{$DB_URL}" up, Goose creates a goose_db_version table to track which migrations have already run, ensuring we don't apply the same change twice.
SQL Queries
With the table created, we can write our queries in sql/queries/users.sql.
1234567-- name: CreateUser :oneINSERT INTO users (id, created_at, updated_at, email, hashed_password)VALUES ($1, $2, $3, $4, $5)RETURNING *;
-- name: GetUserByEmail :oneSELECT * FROM users WHERE email = $1;Note the RETURNING * clause. In older SQL, an INSERT was "blind"—it didn't return data. You would have to insert the row and then immediately run a SELECT to get the data back (a two-round-trip process).
Modern PostgreSQL allows INSERT, UPDATE, and DELETE statements to behave like SELECTs. By using RETURNING *, the database inserts the row and returns the full object in a single, atomic transaction. This is faster and guarantees that the data we get back is exactly what was stored.
Wiring it up in Main
Finally, we need to initialize the database connection in main.go and inject it into our apiConfig struct.
Important: We must import the postgres driver lib/pq using the blank identifier _. This registers the driver with Go's database/sql package without us needing to call its functions directly.
1234567891011121314151617181920212223242526272829303132// top of the fileimport ( "os" "sync/atomic" "database/sql" "time" "github.com/google/uuid" "github.com/joho/godotenv" "github.com/mengkhaiteow/httpserver/internal/database" _ "github.com/lib/pq")
type apiConfig struct { fileserverHits atomic.Int32 query *database.Queries}
func main() { godotenv.Load() dbURL := os.Getenv("DB_URL") db, err := sql.Open("postgres", dbURL) err = db.Ping() if err != nil { log.Fatal("Could not connect to DB:", err) return } dbQueries := database.New(db) apiCfg := &apiConfig{ fileserverHits: atomic.Int32{}, query: dbQueries, }}Helper Functions
Before proceeding to the main function, I needed to create some helper functions to keep the code DRY. Writing the same header setting and error handling logic in every handler is a recipe for messy code.
12345678910111213141516171819type errorResponse struct { Error string `json:"error"`}
func respondWithError(w http.ResponseWriter, code int, msg string) { respondWithJSON(w, code, errorResponse{Error: msg})}
func respondWithJSON(w http.ResponseWriter, code int, payload any) { data, err := json.Marshal(payload) if err != nil { log.Printf("Error marshalling JSON: %s", err) w.WriteHeader(500) return } w.Header().Set("Content-Type", "application/json") w.WriteHeader(code) w.Write(data)}These two functions are the backbone of my API responses. The respondWithJSON() function ensures we always convert the payload to JSON and set the appropriate Content-Type. The respondWithError() wrapper enforces a standard error structure.
Most frontends expect a specific JSON shape to parse data easily:
1234{ "email": "test@example.com", "password": "123"}Frontend developers usually access properties via data.email. This implies two requirements: we return an Object rather than a primitive, and we use camelCase keys.
To understand why respondWithJSON is written this way, I walked through a few failure scenarios.
The Raw String: A Type Error
123func handleTestString(w http.ResponseWriter, r *http.Request) { respondWithJSON(w, 200, "This is just a raw string, not an object!")}Technically, json.Marshal can handle a string. It produces a valid JSON string:
1"This is just a raw string, not an object!"However, this usually breaks the frontend. If the client code expects an object—perhaps trying to access a property like data.result—receiving a primitive string causes a runtime error or undefined behavior. We need to send an object.
Structs without Tags: The Case Mismatch
My next attempt was returning a struct without generic tags:
1234567891011// Returns a struct but WITHOUT json tagsfunc handleTestNoTags(w http.ResponseWriter, r *http.Request) { type User struct { Email string // Capitalized to mark as Exported Password string } respondWithJSON(w, 200, User{ Email: "test@example.com", Password: "123", })}This returns a valid JSON object, but the keys match the Go struct fields exactly:
1{ "Email": "test@example.com", "Password": "123" }In Go, struct fields must be capitalized to be exported and visible to other packages. However, frontends expect lowercase keys. If a JavaScript developer tries user.email, they get undefined. They would have to write user.Email, which violates standard JS conventions.
Lowercase Fields: The "Silent" Failure
I thought I would just make the Go struct fields lowercase to match the JSON requirement.
1234567891011// Returns a struct with lowercase fieldsfunc handleTestPrivate(w http.ResponseWriter, r *http.Request) { type userPrivate struct { email string // Lowercase makes this Unexported and Private password string } respondWithJSON(w, 200, userPrivate{ email: "test@example.com", password: "123", })}This results in an empty object {}.
The failure happens because json.Marshal comes from the encoding/json package. Since email and password start with lowercase letters, they are private to my package. The external JSON package cannot "see" them via reflection, so it simply ignores them.
The Solution: Struct Tags
The only way to satisfy both Go's export rules and JSON's naming conventions is using struct tags:
12345678910func handleUser(w http.ResponseWriter, r *http.Request) { type User struct { Email string `json:"email"` Password string `json:"password"` } respondWithJSON(w, 200, User{ Email: "test@example.com", Password: "123", })}Now, Go sees the exported Email field, but the JSON encoder uses the tag value email for the output.
1234{ "email": "test@example.com", "password": "123"}The "Implicit 200" Trap
There is one more subtle bug to watch out for. If you forget to set the header or status code explicitly, Go makes assumptions.
12345678910func respondWithJSON(w http.ResponseWriter, code int, payload any) { data, err := json.Marshal(payload) // ... error handling ...
// MISTAKE: Forgot to write header or status code! // w.Header().Set("Content-Type", "application/json") // w.WriteHeader(code)
w.Write(data)}Go fails safe here, but in a dangerous way. If you write data without setting a status code, Go implicitly assumes 200 OK and tries to guess the content type.
If you are trying to send an error—such as { "error": "Unauthorized" }—but forget the status code, the frontend receives a 200 OK. A client checking response.ok will think the request succeeded, even though the body contains an error message. Explicitly setting w.WriteHeader(code) is mandatory for correct error handling.
Hash Functions
Create a file internal/auth/auth.go and create the hash function :
12345678910111213package auth
import ( "github.com/alexedwards/argon2id")
func HashPassword(password string) (string, error) { return argon2id.CreateHash(password, argon2id.DefaultParams)}
func CheckPasswordHash(password, hash string) (bool, error) { return argon2id.ComparePasswordAndHash(password, hash)}The first function is to hash the password, the second function is to check if the password is correct. Argon2Id is the latest industry standard, it is even better than Bcrypt.
The User Handler: Parsing and Creating
Now that sqlc has generated our type-safe database functions, we can wire them up in main.go.
Parsing JSON
Since we are building an HTTP server, we deal with streaming data. To read the request body, we use a json.NewDecoderwhich is to attach a JSON processing machine to the request body. Unlike json.Unmarshal (which requires loading the whole JSON into memory first), the decoder processes the stream directly from the request body, which is more efficient for an HTTP server.
We define a struct to match the expected JSON structure. (Note: You can define this struct inside the handler function to keep the global namespace clean).
1234567891011121314151617
func (cfg *apiConfig) handleUser(w http.ResponseWriter, r *http.Request) { type parameters struct { Email string `json:"email"` Password string `json:"password"` }
decoder := json.NewDecoder(r.Body) params := parameters{} err := decoder.Decode(¶ms) if err != nil { respondWithError(w, http.StatusBadRequest, "Invalid request payload") return }
// ... continued below}In the line decode.Decode(¶ms):
- Scanning: It reads the JSON key - "email" from the stream.
- Matching: It looks at the parameter struct, search for the field that has struct tag
json:"email" - Type Checking : It checks if the value in the JSON matched the type in the parameter struct, check if it is truly a string type
- Assignment: It reaches into the memory address provided
¶ms, and write the value directly there. Which is why later in the function you can access it throughparams.email
Context: The "Kill Switch"
When calling our database function, you'll notice we pass r.Context():
12345678910111213 hashedPassword, err := auth.HashPassword(params.Password) if err != nil { respondWithError(w, http.StatusInternalServerError, "Couldn't hash password") return }
user, err := cfg.query.CreateUser(r.Context(), database.CreateUserParams{ ID: uuid.New(), // Generate the ID here! CreatedAt: time.Now().UTC(), UpdatedAt: time.Now().UTC(), Email: params.Email, HashedPassword: hashedPassword, })Why pass context? Context acts as a kill switch for the request.
Imagine a user sends a complex search query that takes the database 30 seconds to process. If the user gets impatient and closes their browser tab after 2 seconds, the request is cancelled.
- The server receives the cancellation signal.
- The
r.Context()becomes "Done". - The database driver sees this and immediately stops the query, freeing up resources.
Without context, your database would keep crunching numbers for a ghost user.
The Importance of DTOs (Data Transfer Objects)
After creating the user, we need to return the data. It is tempting to simply return the struct generated by sqlc:
123456// Generated by SQLCtype User struct { ID uuid.UUID `json:"id"` Email string `json:"email"` HashedPassword string `json:"hashed_password"` // <--- DANGER}However, we should never return this directly. We need to create a custom "Response Struct" (or DTO) for three reasons:
1. Security: The Leaky Abstraction
The internal model contains implementation details like HashedPassword. Even if it's hashed, you never want to expose security internals to the frontend.
2. Aggregated Fields Internal database normalization rules don't always match what the UI needs.
- Database: Stores
first_nameandlast_namecolumns. - API: Returns a computed
full_namefield.
By using a DTO, we can format the data for the user without cluttering the database with redundant columns.
3. API Stability: Compile vs. Runtime Errors
This is the most subtle but powerful reason. Imagine we rename the database column content to message.
- Without DTO:
sqlcupdates the JSON tag automatically. The API response changes from{"content": "..."}to{"message": "..."}. The frontend code (which expectscontent) breaks silently in production—a dangerous Runtime Error. - With DTO: You have to manually map
dbUser.MessagetodtoUser.Content. When you rename the DB column, your mapping code breaks immediately because the fieldMessagedoesn't exist on the struct yet. The compiler forces you to fix it—a strict Compile-time Error.
We would always rather fix a bug at compile time than have it crash in production.
1234567891011121314151617// Define a clean response structtype User struct { ID uuid.UUID `json:"id"` Email string `json:"email"` CreatedAt time.Time `json:"created_at"`}
// Map internal -> externalfunc (cfg *apiConfig) handleUsersCreate(w http.ResponseWriter, r *http.Request) { // ... create user logic ...
respondWithJSON(w, http.StatusCreated, User{ ID: user.ID, Email: user.Email, CreatedAt: user.CreatedAt, })}Create User
After understanding all these different concept, this handleUser() would be relatively simple to understand.
123456789101112131415161718192021222324252627282930313233343536373839func (cfg *apiConfig) handleUser(w http.ResponseWriter, r *http.Request) { type parameter struct { Email string `json:"email"` Password string `json:"password"` }
decoder := json.NewDecoder(r.Body) params := parameter{} err := decoder.Decode(¶ms) if err != nil { respondWithError(w, 400, "Something went wrong") return }
hashedPassword, err := auth.HashPassword(params.Password) if err != nil { respondWithError(w, 500, "Couldn't hash password") return } user, err := cfg.query.CreateUser(r.Context(), database.CreateUserParams{ ID: uuid.New(), // Must generate UUID CreatedAt: time.Now().UTC(), // Must generate timestamp UpdatedAt: time.Now().UTC(), Email: params.Email, HashedPassword: hashedPassword, }) if err != nil { log.Printf("Error creating user: %v", err) respondWithError(w, 500, "Something went wrong when creating user") return } else { respondWithJSON(w, 201, User{ ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, Email: user.Email, }) }}Authentication with JWTs
Now that we can create users, we need a way to log them in.
In the past, servers used sessions stored in the database to track logged-in users. However, for this project, we are building a stateless API. This means the server will not remember who is logged in between requests. Instead, the client must present a digital "badge" with every request to prove their identity.
This badge is a JSON Web Token or JWT.
We need to update the apiConfig struct by creating a jwtSecret field, so that it can hold the JWTs secret key.
1234567891011121314151617181920type apiConfig struct { fileserverHits atomic.Int32 query *database.Queries jwtSecret string // <--- Add this}
func main() { // ... jwtSecret := os.Getenv("JWT_SECRET") if jwtSecret == "" { log.Fatal("JWT_SECRET environment variable is not set") }
apiCfg := &apiConfig{ fileserverHits: atomic.Int32{}, query: dbQueries, jwtSecret: jwtSecret, // <--- Add this } // ...}The Auth Deep Dive
Implementing robust authentication involves handling cryptography, expiration times, and refresh tokens. It is a complex topic that deserves its own space to explore properly.
I have written a complete deep dive on the architecture of this system here: Go Authorization and Authentication.
I highly recommend reading that post to understand the security reasoning behind Refresh Tokens and the "Why" of our architecture. For this server build, we will focus strictly on wiring up the handleLogin endpoint using the auth package we designed in that post.
Wiring the Login Handler
The login handler is responsible for three specific tasks:
- Verification: Check if the provided password matches the stored hash.
- Generation: Create the Access and Refresh tokens.
- Response: Return the tokens along with the non-sensitive user profile.
Before writing the Go implementation, we must prepare the database. We need a table to store the long-lived refresh tokens.
123456789101112-- +goose UpCREATE TABLE refresh_tokens ( token TEXT PRIMARY KEY, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, expires_at TIMESTAMP NOT NULL, revoked_at TIMESTAMP);
-- +goose DownDROP TABLE refresh_tokens;We also need to define the queries for sqlc to generate our type-safe Go interface. We need three specific operations: creating a token, retrieving a user by their token, and revoking a token.
1234567891011121314151617181920212223-- name: CreateRefreshToken :oneINSERT INTO refresh_tokens (token, created_at, updated_at, user_id, expires_at, revoked_at)VALUES ( $1, $2, $3, $4, $5, NULL)RETURNING *;
-- name: GetUserFromRefreshToken :oneSELECT users.* FROM usersJOIN refresh_tokens ON users.id = refresh_tokens.user_idWHERE refresh_tokens.token = $1AND refresh_tokens.expires_at > $2AND refresh_tokens.revoked_at IS NULL;
-- name: RevokeRefreshToken :execUPDATE refresh_tokensSET revoked_at = $2, updated_at = $2WHERE token = $1;After saving these files, run sqlc generate in your terminal. This will update the internal/database package with the new struct definitions and methods.
Now we can implement the handleLogin endpoint.
You will notice a small block of logic handling ExpiresInSeconds. This allows clients to request a very short token duration (capped at 1 hour). This is primarily useful for automated testing, allowing us to verify token expiration without waiting a full hour.
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273func (cfg *apiConfig) handleLogin(w http.ResponseWriter, r *http.Request) { type parameter struct { Email string `json:"email"` Password string `json:"password"` ExpiresInSeconds int `json:"expires_in_seconds"` }
decoder := json.NewDecoder(r.Body) params := parameter{} err := decoder.Decode(¶ms) if err != nil { respondWithError(w, http.StatusInternalServerError, "Couldn't decode parameters") return }
user, err := cfg.query.GetUserByEmail(r.Context(), params.Email) if err != nil { respondWithError(w, http.StatusUnauthorized, "Incorrect email or password") return }
err = auth.CheckPasswordHash(params.Password, user.HashedPassword) if err != nil { respondWithError(w, http.StatusUnauthorized, "Incorrect email or password") return }
// Default to 1 hour, unless a shorter time is requested for testing expirationTime := time.Hour if params.ExpiresInSeconds > 0 && time.Duration(params.ExpiresInSeconds)*time.Second < expirationTime { expirationTime = time.Duration(params.ExpiresInSeconds) * time.Second }
accessToken, err := auth.MakeJWT(user.ID, cfg.jwtSecret, expirationTime) if err != nil { respondWithError(w, http.StatusInternalServerError, "Couldn't create access token") return }
refreshTokenStr, err := auth.MakeRefreshToken() if err != nil { respondWithError(w, http.StatusInternalServerError, "Couldn't create refresh token") return }
now := time.Now().UTC()
_, err = cfg.query.CreateRefreshToken(r.Context(), database.CreateRefreshTokenParams{ Token: refreshTokenStr, UserID: user.ID, CreatedAt: now, UpdatedAt: now, ExpiresAt: time.Now().Add(time.Hour * 24 * 60), // 60 Days })
// Embed the User DTO into the response type response struct { User Token string `json:"token"` RefreshToken string `json:"refresh_token"` }
respondWithJSON(w, http.StatusOK, response{ User: User{ ID: user.ID, CreatedAt: user.CreatedAt, UpdatedAt: user.UpdatedAt, Email: user.Email, }, Token: accessToken, RefreshToken: refreshTokenStr, })}By embedding the User struct inside our response, we automatically flatten the JSON. The client receives a clean object containing the user details alongside the two tokens needed for future requests.
Closing the loop: Authorization vs. Authentication
We have spent a lot of time on Authentication — verifying the user's identity via JWTs. But identity is only half the battle. We also need Authorization — verifying that this user has permission to perform a specific action.
The most common mistake in backend development is assuming that a valid JWT means "Admin Access".
The Ownership Check
We need a resource to manage. While most social media platforms have "posts," in this project we call them "Chirps." We start by defining the schema for this new item.
1234567891011-- +goose UpCREATE TABLE chirps ( id UUID PRIMARY KEY, created_at TIMESTAMP NOT NULL, updated_at TIMESTAMP NOT NULL, body TEXT NOT NULL, user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE);
-- +goose DownDROP TABLE chirpsJust like with users and tokens, we write queries to handle these items so sqlc can generate our type-safe Go functions.
123456789101112131415161718192021222324252627-- name: CreateChirp :oneINSERT INTO chirps(id, created_at, updated_at, body, user_id)VALUES ( gen_random_uuid(), NOW(), NOW(), $1, $2)RETURNING *;
-- name: GetChirps :manySELECT * FROM chirpsORDER BY created_at ASC;
-- name: GetChirp :oneSELECT * FROM chirpsWHERE id = $1;
-- name: DeleteChirp :execDELETE FROM chirpsWHERE id = $1 AND user_id = $2;
-- name: GetChirpsForAuthor :manySELECT * FROM chirpsWHERE user_id = $1ORDER BY created_at ASC;I will not detail every CRUD function here as the patterns for creating and reading are similar to users. However, the deleteChirp endpoint warrants a deep dive because it introduces Authorization.
It is not enough to simply check if the user is logged in. We must ensure they own the specific chirp they are trying to delete.
We rely on the GetBearerToken helper to extract the JWT from the request headers. For a full breakdown of how we parse and validate these tokens, refer to my deep dive on Go Authorization and Authentication.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647func (cfg *apiConfig) deleteChirp(w http.ResponseWriter, r *http.Request) { // 1. Authenticate: Is this a valid user token, err := auth.GetBearerToken(r.Header) if err != nil { respondWithError(w, http.StatusUnauthorized, "Couldn't find JWT") return } userID, err := auth.ValidateJWT(token, cfg.jwtSecret) if err != nil { respondWithError(w, http.StatusUnauthorized, "Couldn't validate JWT") return }
// 2. Parse the ID from the URL chirpIDString := r.PathValue("chirpID") chirpID, err := uuid.Parse(chirpIDString) if err != nil { log.Printf("Invalid chirp ID: %v", err) respondWithError(w, 400, "Couldn't parse chirp ID") return }
// 3. Database lookup: Who own this chirp chirp, err := cfg.query.GetChirp(r.Context(), chirpID) if err != nil { respondWithError(w, http.StatusNotFound, "Chirp not found") return }
// 4. Authorize: Does the requester match the owner if chirp.UserID != userID { respondWithError(w, http.StatusForbidden, "You are not the author of this chirp") return }
// 5. Execute err = cfg.query.DeleteChirp(r.Context(), database.DeleteChirpParams{ ID: chirpID, UserID: userID, }) if err != nil { respondWithError(w, http.StatusInternalServerError, "Couldn't delete chirp") return }
w.WriteHeader(http.StatusNoContent)}Notice the distinction between 401 Unauthorized and 403 Forbidden:
- 401: "I don't know who you are". The token is missing or invalid.
- 403: "I know who you are, but you can't do this". The token is valid, but the user does not own the resource.
This pattern — Authenticate -> Fetch Resources -> Check Ownership -> Execute formed the backbone of secure RESTful design.
Wiring it All Together
Finally, with all our endpoints created, we must register them to our server mux so the application knows how to route traffic. We map each HTTP verb and path to the corresponding handler function in main().
123456789func main(){ // ... database connection and config setup ...
mux.HandleFunc("POST /api/users", apiCfg.handleUser) mux.HandleFunc("POST /api/login", apiCfg.handleLogin) mux.HandleFunc("DELETE /api/chirps/{chirpID}", apiCfg.deleteChirp)
server.ListenAndServe()}Conclusion
We started this journey with a simple TCP listener and evolve it into a robust backend server. We tackled database migrations with Postgres, type-safe SQL with sqlc, and a stateless authentication system using JWTs.
While there are many features left to add — webhooks, image processing, or stricter validation — the foundation is solid. The pattern we established here — configuration struct, dependency injection, and DTOs will support the application as it scales from a single main.go file into a complex microservice.