xenonnn4wxenonnn4w
build log · Go / PostgreSQL

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.

Go 1.24
standard-library HTTP server
14
explicit method-aware routes
1 hour
access-token lifetime
60 days
refresh-token lifetime

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.

HTTP clientuntrusted inputnet/httpmethod routeshandlersauth + JSONsqlctyped queriesPostgresstatecontext carries cancellation across the boundary
The deliberately short request path: parse, authorize, execute a typed query, respond.
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.

POST/api/userscreate an accountopen
POST/api/loginissue access + refresh tokensopen
GET · POST/api/chirpslist or publish chirpsmixed
GET · DELETE/api/chirps/{id}read or owner-deletemixed
POST/api/refresh · /api/revokecontinue or end a sessiontoken
POST/api/polka/webhooksapply a paid upgradekey

Authentication 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.

01

Login

Argon2id verifies the password.

02

Use

A 1-hour HS256 JWT authenticates API calls.

03

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.

Tags

#go#backend#postgresql#sqlc#jwt#argon2id#rest-api