Day 54 — auth basics (JWT / session)

Updated

July 30, 2026

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.

Warning

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:

  1. Signature with secret/public key
  2. exp not in the past
  3. iss/aud if 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 4 — Session cookies

http.SetCookie(w, &http.Cookie{
    Name:     "session",
    Value:    sessionID,
    Path:     "/",
    HttpOnly: true,
    Secure:   true, // HTTPS only in real deploy; false only on local HTTP with eyes open
    SameSite: http.SameSiteLaxMode,
    MaxAge:   int((24 * time.Hour).Seconds()),
})

Store sessionID → userID, expires in DB or memory map (memory dies on restart—ok for lab). On logout: delete server row and expire cookie.

http.SetCookie(w, &http.Cookie{
    Name: "session", Value: "", Path: "/", MaxAge: -1, HttpOnly: true,
})

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/day54

Write 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/me returns current user public profile (no hash!)
  • POST /v1/items requires auth; set owner_id from context
  • GET /v1/items lists only own items or all public—document policy

Lab 4 — Tests

  1. No token → 401 on protected route
  2. Login → token/cookie → 200 on /v1/me
  3. Wrong password → 401
  4. Tampered JWT → 401 (JWT track)
  5. Access another user’s item → 403 (if ownership enforced)
go test ./... -race -count=1

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