Skip to content

Day 104 — JWT Auth & RBAC

Month 4 · Week 3 · ⬅ Day 103 · Day 105 ➡ · Journal index

🎯 Learning Objective

Sign and verify an HS256 JWT with the stdlib, then gate routes by role (RBAC) — verifying the signature before trusting any claim.

📚 Topics

  • JWT anatomy: header.payload.signature; HMAC-SHA256; base64.RawURLEncoding
  • Authentication vs authorization; 401 vs 403; alg-pinning; exp

📖 Reading / Sources

📝 Notes

  • A JWT is three base64url segments joined by dots: base64url(header) "." base64url(payload) "." base64url(signature). Header+payload are encoded, not encrypted — anyone can read them; the signature only proves integrity. → [[jwt-anatomy]]
  • Use base64.RawURLEncoding (URL-safe alphabet, no = padding) exactly as the spec requires — not StdEncoding.
  • Verify the signature BEFORE trusting the payload. Recompute HMAC-SHA256 over the received header.payload and compare with hmac.Equal (constant-time — == leaks timing). Only then unmarshal and act on claims. → [[verify-before-trust]]
  • Pin the algorithm. Reject any token whose header alg != "HS256". The classic attack flips alg to "none" (skip the check) or "RS256" (verify the HMAC secret as if it were a public key). → [[alg-confusion-attack]]
  • Check exp (expiry, Unix seconds) on every verify; inject now so the check is unit-testable.
  • AuthN ≠ AuthZ. Authentication = "who are you?" (valid token) → 401 Unauthorized when it fails. Authorization = "are you allowed?" (right role) → 403 Forbidden when it fails. Don't return 401 for a wrong role. → [[401-vs-403]]
  • RBAC middleware: extract Bearer <token> (strings.CutPrefix), verify, then compare claims.Role to the required role before calling next.
  • HS256 uses one shared secret (symmetric): fine for a monolith. Across services prefer RS256/ES256 (asymmetric) so verifiers hold only a public key.
  • In production use github.com/golang-jwt/jwt/v5 — it handles parsing, alg-pinning, exp/nbf/iat, and clock skew. Roll your own only to learn. → [[reach-for-stdlib-first]]

💻 Code Examples

// Verify checks structure, algorithm, signature, THEN expiry — and only then
// returns trusted claims. now is injected for testability.
func Verify(token string, secret []byte, now time.Time) (Claims, error) {
    parts := strings.Split(token, ".")
    if len(parts) != 3 {
        return Claims{}, ErrFormat
    }
    // 1. Pin the algorithm from the decoded header.
    hdr, _ := base64.RawURLEncoding.DecodeString(parts[0])
    var h struct{ Alg string `json:"alg"` }
    if json.Unmarshal(hdr, &h) != nil || h.Alg != "HS256" {
        return Claims{}, ErrAlg
    }
    // 2. Constant-time signature check over the EXACT received bytes.
    expected := sign(parts[0]+"."+parts[1], secret)
    if !hmac.Equal([]byte(expected), []byte(parts[2])) {
        return Claims{}, ErrSignature
    }
    // 3. Only now is it safe to trust the payload.
    var c Claims
    pl, _ := base64.RawURLEncoding.DecodeString(parts[1])
    if json.Unmarshal(pl, &c) != nil {
        return Claims{}, ErrFormat
    }
    if now.Unix() >= c.Exp {
        return Claims{}, ErrExpired
    }
    return c, nil
}

Full signer/verifier + RBAC middleware: examples/month-04/jwt/main.go · Run: go run ./examples/month-04/jwt

🏋️ Exercises / Practice

Exercise Status Link
HS256 sign/verify with alg-pinning + hmac.Equal (in example) examples/month-04/jwt
RequireRole RBAC middleware: 401 vs 403 (in example) examples/month-04/jwt

🐛 Mistakes Made

  • Decoded and used the payload before checking the signature — a forged token would have been trusted. Reordered: verify, then unmarshal.
  • Compared signatures with ==; switched to hmac.Equal to avoid timing leaks.
  • Returned 401 when an authenticated user lacked the role; that's 403.

❓ Open Questions

  • Refresh tokens & rotation: short-lived access JWT + long-lived refresh token — where to store/revoke them (stateless JWTs can't be revoked before exp).

🧠 Active Recall (answer without looking)

  1. Q: Why must you verify the signature before decoding/trusting the claims, and why pin the alg?

    A The payload is only base64 — trivially forgeable. Verifying first ensures the token came from someone holding the secret. Pinning `alg` blocks the `"none"` and RS256↔HS256 confusion attacks that bypass the HMAC check entirely.

  2. Q: A valid token whose owner lacks the required role — which status code?

    A `403 Forbidden`. Authentication succeeded (we know who they are), authorization failed (they're not allowed). `401 Unauthorized` is for missing/invalid tokens.

🪶 Feynman Reflection

A JWT is a tamper-evident sticker: the server writes who you are and stamps it with a secret-keyed HMAC. Anyone can read the sticker, but only the server can make a valid stamp, so a forged one fails the check. I always re-stamp the received bytes and compare in constant time before believing a word of the payload — and I pin the algorithm so nobody talks me into skipping the stamp. Knowing who you are (401) is separate from whether you're allowed (403).

🕳️ Knowledge Gaps

  • Asymmetric signing (RS256/ES256) key management and JWKS rotation across services.

✅ Summary

I can sign and verify an HS256 JWT with the stdlib, pin the algorithm, compare signatures with hmac.Equal, enforce expiry, and gate routes by role with the correct 401-vs-403 semantics.

⏭️ Next Steps / Prep for Tomorrow

  • Day 105: Week 3 review + closed-book recall.

Time spent Difficulty Confidence
90 min 🟦🟦🟦⬜⬜ 🟦🟦🟦⬜⬜

Suggested commit: feat(examples): stdlib HS256 JWT sign/verify and RBAC middleware (day 104)