Chirpy: a Go API from routes to refresh tokens
15th September 2026
Chirpy is a small Twitter-style service, but it crosses most of the boundaries that make backend work interesting: untrusted JSON, passwords, short- and long-lived credentials, relational state, ownership checks, and a webhook arriving from another system.
I built it directly on Go's net/http package instead of hiding the request lifecycle behind a framework. PostgreSQL owns the durable state, sqlc turns SQL into typed Go methods, and the handlers remain small enough to trace from socket to row. The source is on GitHub.
The request path
The architecture is intentionally plain. http.ServeMux owns routing. Each handler decodes and validates its input, extracts an identity when the route is protected, and calls a generated query with the request context. That makes cancellation and deadlines flow down to the database without inventing a service layer the project does not need yet.
mux.HandleFunc("POST /api/login", apiCfg.handlerLogin)
mux.HandleFunc("POST /api/chirps", apiCfg.handlerCreateChirp)
mux.HandleFunc("DELETE /api/chirps/{chirpID}", apiCfg.handlerDeleteChirp)
mux.HandleFunc("POST /api/polka/webhooks", apiCfg.handlerPolkaWebhook)Routes are the contract
Go's method-aware patterns keep the contract visible in one place. Public routes create users and sessions. Bearer-protected routes mutate user or chirp state. The Polka upgrade hook uses a separate API-key scheme because it represents a service, not a user.
/api/userscreate an accountopen/api/loginissue access + refresh tokensopen/api/chirpslist or publish chirpsmixed/api/chirps/{id}read or owner-deletemixed/api/refresh · /api/revokecontinue or end a sessiontoken/api/polka/webhooksapply a paid upgradekeyAuthentication is a lifecycle, not one token
Passwords are stored as Argon2id hashes. A successful login returns a signed JWT for fast access checks and a random opaque refresh token stored in PostgreSQL. The access token expires after one hour; the refresh token can survive for 60 days, but the server can revoke it immediately by stamping revoked_at.
Login
Argon2id verifies the password.
Use
A 1-hour HS256 JWT authenticates API calls.
Continue
A stored 60-day opaque token mints a new JWT or is revoked.
This split gives each credential one job. The JWT is cheap to verify and short-lived. The refresh token requires a database lookup, which is exactly what makes revocation possible. The refresh query encodes both rules—unexpired and not revoked—rather than trusting every caller to remember them.
SELECT u.* FROM users u
JOIN refresh_tokens rt ON u.id = rt.user_id
WHERE rt.token = $1
AND rt.expires_at > NOW()
AND rt.revoked_at IS NULL;Ownership belongs in the query
Authentication answers “who called?” Authorization answers “may that caller touch this object?” The delete handler loads the chirp, compares its user_id with the JWT subject, and returns 403 for a different owner. The SQL repeats the constraint so the mutation itself is owner-qualified.
DELETE FROM chirps
WHERE id = $1 AND user_id = $2;Webhooks cross another trust boundary
The user.upgraded event arrives with an API key, not a user JWT. Chirpy checks that key before accepting a user ID and setting is_chirpy_red. Other event types are acknowledged without changing state. That small distinction prevents an integration endpoint from becoming a generic account-mutation route.
What I would harden next
Chirpy is a learning service, not a claim of production readiness. The next pass would centralize JSON/error responses, limit request bodies, validate configuration before serving traffic, remove sensitive values from logs, rotate refresh tokens, and expand handler/database integration coverage. The first focused follow-up is already complete: a separate security lab that makes the JWT policy executable.