A small, production-shaped JWT authentication service in Go. Built as a focused showcase: real auth fundamentals (bcrypt, refresh-token rotation, single-use redemption), Postgres-backed, instrumented with Prometheus metrics + OpenTelemetry tracing.
Scope note. This is intentionally minimal — it covers the email/password + JWT path well rather than covering every protocol poorly. OAuth/OIDC/SAML are listed as next steps below, not pretended to be implemented.
- Email/password registration with bcrypt hashing
- JWT access tokens (HS256, short-lived, configurable TTL)
- Refresh tokens stored as SHA-256 hashes in Postgres, rotated on every redemption (single-use; reuse fails)
- Logout revokes all of a user's refresh tokens
- Per-IP + per-email login rate limiting (
golang.org/x/time/rate) - Constant-time login (dummy bcrypt on unknown email to keep timing similar)
/healthz,/metrics(Prometheus), OpenTelemetry HTTP instrumentation- Structured JSON logging via
log/slog - Goose-style migrations (golang-migrate format)
- Table-driven tests against an in-memory store; CI runs
go vet+go test -race -cover
cmd/server/ wiring & graceful shutdown
internal/auth/ domain logic — Service exposes Register/Login/Refresh/Logout
internal/store/ pgx-backed Store implementing auth.Store
internal/httpapi/ chi router, handlers, rate limiter, auth middleware
internal/observability/ Prometheus + OTel HTTP middleware
migrations/ golang-migrate SQL files
loadtest/ k6 scripts for /v1/login and /v1/refresh
┌──────────────────────────────────────┐
client ─── POST ───▶ │ observability (otelhttp + metrics)│
│ │ │
│ ▼ │
│ chi router │
│ │ │
│ ├── /v1/login ─┐ │
│ ├── /v1/refresh ─┤ │
│ └── /v1/me ─┤ middleware │
│ (Bearer JWT) ─┘ validates │
│ │ │
│ ▼ │
│ auth.Service (bcrypt, JWT, rotate)│
│ │ │
│ ▼ │
│ store.Store (pgx → Postgres) │
└──────────────────────────────────────┘
The split between auth.Service (domain) and store.Store (persistence) is deliberate: tests run against an in-memory auth.Store implementation, so domain behaviour is verified without spinning up Postgres.
Refresh tokens live longer than access tokens, so they're a juicier target. We mitigate by:
- Storing only
sha256(token)in the DB — a DB leak doesn't yield usable tokens. - Rotating on every refresh — the redemption is an atomic
UPDATE ... SET revoked_at = now() WHERE revoked_at IS NULL RETURNING user_id. If the same token is presented twice, the second attempt finds zero rows and fails. - Logout revokes all of a user's refresh tokens, not just the current one.
make db-up # starts Postgres in Docker, runs migrations
make run # starts the server on :8080
make test # runs all tests with race detector# Register
curl -s localhost:8080/v1/register -d '{"email":"a@b.com","password":"hunter2hunter"}'
# Login
TOKENS=$(curl -s localhost:8080/v1/login -d '{"email":"a@b.com","password":"hunter2hunter"}')
ACCESS=$(echo $TOKENS | jq -r .access_token)
REFRESH=$(echo $TOKENS | jq -r .refresh_token)
# Authenticated call
curl -s localhost:8080/v1/me -H "Authorization: Bearer $ACCESS"
# Refresh (returns a new pair; old refresh token is now invalid)
curl -s localhost:8080/v1/refresh -d "{\"refresh_token\":\"$REFRESH\"}"
# Logout (revokes all refresh tokens for the user)
curl -s -X POST localhost:8080/v1/logout -H "Authorization: Bearer $ACCESS"loadtest/ contains k6 scripts for the two hot paths:
# /v1/login — bcrypt-bound, 50 VUs × 30s with p95<200ms / p99<500ms thresholds
k6 run loadtest/login.js
# /v1/refresh — verifies atomic single-use rotation holds under concurrency
k6 run loadtest/refresh.jsSee loadtest/README.md for tuning.
/metricsexposes Prometheus counters/histograms for request rate, status code, and latency per endpoint.- All inbound HTTP requests are wrapped with
otelhttp.NewHandler, so spans propagate automatically when an OTLP exporter is configured. - All logs are structured JSON via
slog.
- OAuth / OIDC / SAML — meaningful implementations are large; faking them would be worse than omitting them. Natural next step.
- Email verification & password reset — would need an email transport abstraction and a token table.
- MFA / WebAuthn — separate problem space.
- Distributed rate limiting — current limiter is in-memory; for multi-instance deploys this would move to Redis.
- Frontend — out of scope.
Go 1.23 · chi · pgx/v5 · golang-jwt/jwt/v5 · bcrypt · prometheus/client_golang · OpenTelemetry · golang-migrate