Day 54 — auth basics (JWT / session)
Day 54 — auth basics (JWT / session)
Stage VI · ~3h
Goal: Protect API routes with either HTTP sessions (cookie) or JWT bearer tokens—know the trade-offs, implement one end-to-end with middleware, and avoid classic secret mistakes.
Why this day exists
Open APIs are demos. Real services need:
- Authentication — who are you?
- Authorization — what may you do?
Today focuses on authn + a simple “authenticated vs not” gate; role-based authz can be a thin claim/role check. Stage VI gate expects protected write paths.
This is literacy, not a full threat model. Do not invent crypto. Prefer proven libraries for JWT parsing when you leave pure-stdlib demos.
Theory 1 — Session vs JWT
| Server session | JWT access token | |
|---|---|---|
| State | Server stores session id → user | Token carries claims; server verifies signature |
| Revocation | Delete session row/store | Hard until expiry (need blocklist or short TTL) |
| Cookies | Often HttpOnly session cookie |
Can be cookie or Authorization header |
| Scaling | Shared session store | Stateless verify (until refresh/revocation needs) |
| Size | Small cookie id | Larger token |
Pick one for the lab. Document why. Capstone may use sessions for browsers and tokens for service clients—today keep one path clear.
Mental model
Session: browser → cookie(session_id) → server lookup → user_id
JWT: client → Authorization: Bearer <jwt> → verify sig+exp → user_id
Theory 2 — Password handling (minimum)
import "golang.org/x/crypto/bcrypt"
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
err = bcrypt.CompareHashAndPassword(hash, []byte(password))Use golang.org/x/crypto/bcrypt (not stdlib, but standard practice). Never store plaintext passwords. Never log passwords. Cost factor trades CPU for hardness—default is fine for labs.
For pure-stdlib-only constraint days you can use a dev fixed token—but label it non-production.
// DEV ONLY — refuse in production builds
if password != os.Getenv("DEV_PASSWORD") {
return errAuth
}Theory 3 — JWT shape (conceptual)
header.payload.signature
Claims (examples):
{
"sub": "user-123",
"exp": 1893456000,
"iat": 1893452000,
"role": "user"
}Verify:
- Signature with secret/public key
expnot in the past
iss/audif you set them
HS256 with a long random secret is common for labs; asymmetric keys for multi-service prod.
// sketch with github.com/golang-jwt/jwt/v5
token, err := jwt.ParseWithClaims(tok, &Claims{}, func(t *jwt.Token) (any, error) {
if t.Method != jwt.SigningMethodHS256 {
return nil, fmt.Errorf("unexpected alg")
}
return secret, nil
})
if err != nil || !token.Valid {
return "", errAuth
}Reject alg=none. Do not trust unverified claims. Set short exp (e.g. 15–60 minutes) even in labs so expiry code paths get exercised.
Theory 5 — Auth middleware
type userKey struct{}
func (s *Server) requireAuth(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
userID, err := s.authenticate(r)
if err != nil {
writeErr(w, http.StatusUnauthorized, "unauthorized", "login required")
return
}
ctx := context.WithValue(r.Context(), userKey{}, userID)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
func UserID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(userKey{}).(string)
return id, ok && id != ""
}Route strategy
- Public:
POST /v1/login,POST /v1/register,GET /healthz
- Protected: mount subtree or wrap individual handlers
s.mux.Handle("GET /v1/me", s.requireAuth(http.HandlerFunc(s.me)))
s.mux.Handle("POST /v1/items", s.requireAuth(http.HandlerFunc(s.createItem)))Note: Handle + middleware-wrapped Handler vs HandleFunc—be consistent with method patterns on Go 1.22+ mux.
Theory 6 — Authn vs authz (do not merge them)
| Concern | Example |
|---|---|
| Authn failure | Missing/invalid token → 401 |
| Authz failure | Valid user, not owner → 403 |
it, err := s.repo.Get(ctx, id)
// ...
if it.OwnerID != userID {
writeErr(w, http.StatusForbidden, "forbidden", "not your item")
return
}Logging: record user_id after auth, never the raw password or full bearer token.
Worked example — bearer token (simple HMAC JWT lab)
Login:
func (s *Server) login(w http.ResponseWriter, r *http.Request) {
var req struct {
Email string `json:"email"`
Password string `json:"password"`
}
if err := readJSON(r, &req); err != nil {
writeErr(w, http.StatusBadRequest, "bad_request", "invalid json")
return
}
u, err := s.users.ByEmail(r.Context(), req.Email)
if err != nil || bcrypt.CompareHashAndPassword(u.Hash, []byte(req.Password)) != nil {
// constant-ish message; avoid user enumeration in messages if you can
writeErr(w, http.StatusUnauthorized, "unauthorized", "invalid credentials")
return
}
tok, err := s.issueToken(u.ID)
if err != nil {
writeErr(w, http.StatusInternalServerError, "internal", "internal error")
return
}
writeJSON(w, http.StatusOK, map[string]string{
"access_token": tok,
"token_type": "Bearer",
})
}Authenticate:
func (s *Server) authenticate(r *http.Request) (string, error) {
h := r.Header.Get("Authorization")
const p = "Bearer "
if !strings.HasPrefix(h, p) {
return "", errAuth
}
return s.parseToken(strings.TrimPrefix(h, p))
}Lab 1 — Choose track
mkdir -p ~/lab/90daysofx/01-go/day54
cd ~/lab/90daysofx/01-go/day54
go mod init example.com/day54Write in README: Track = JWT or Track = Session. One paragraph trade-off.
Lab 2 — User store
Table users(id, email, password_hash, created_at). Register + login endpoints. Unique email → 409.
CREATE TABLE users (
id TEXT PRIMARY KEY,
email TEXT NOT NULL UNIQUE,
password_hash BLOB NOT NULL,
created_at TEXT NOT NULL
);Lab 3 — Protect routes
GET /v1/mereturns current user public profile (no hash!)
POST /v1/itemsrequires auth; setowner_idfrom context
GET /v1/itemslists only own items or all public—document policy
Lab 4 — Tests
- No token → 401 on protected route
- Login → token/cookie → 200 on
/v1/me
- Wrong password → 401
- Tampered JWT → 401 (JWT track)
- Access another user’s item → 403 (if ownership enforced)
go test ./... -race -count=1Lab 5 — Secrets
Load signing secret / session keys from env AUTH_SECRET. Refuse to start if missing or too short (< 32 bytes) in non-dev mode. Dev default only with loud log warning:
secret := os.Getenv("AUTH_SECRET")
if secret == "" {
if dev {
log.Warn("AUTH_SECRET empty; using insecure dev secret")
secret = "dev-only-change-me-dev-only-change-me"
} else {
log.Error("AUTH_SECRET required")
os.Exit(1)
}
}Common gotchas
| Gotcha | Fix |
|---|---|
| JWT without exp | Always set expiry |
| Secrets in git | Env / secret manager |
alg confusion attacks |
Enforce expected algorithm |
| Cookie without HttpOnly | Set HttpOnly |
| Logging Authorization header | Never |
| Authz = authn | Separate role/owner checks |
| Timing-safe compare for tokens | Use hmac.Equal / library |
| Returning password_hash in JSON | Explicit public DTO |
Checkpoint
- Register + login works
- Passwords hashed
- Middleware enforces auth
- 401 paths tested
- Secret from env
- Trade-off documented (JWT vs session)
Commit
git add .
git commit -m "day54: auth middleware JWT or session"Write three personal gotchas before continuing.
Tomorrow
Day 55 — validation & problem details: consistent input validation and structured API errors clients can trust.