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;
401vs403; alg-pinning;exp
📖 Reading / Sources¶
-
crypto/hmac·hmac.Equal -
encoding/base64— RawURLEncoding - RFC 7519 — JSON Web Token ·
github.com/golang-jwt/jwt
📝 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 — notStdEncoding. - Verify the signature BEFORE trusting the payload. Recompute HMAC-SHA256 over
the received
header.payloadand compare withhmac.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 flipsalgto"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; injectnowso the check is unit-testable. - AuthN ≠ AuthZ. Authentication = "who are you?" (valid token) →
401 Unauthorizedwhen it fails. Authorization = "are you allowed?" (right role) →403 Forbiddenwhen it fails. Don't return401for a wrong role. → [[401-vs-403]] - RBAC middleware: extract
Bearer <token>(strings.CutPrefix), verify, then compareclaims.Roleto the required role before callingnext. - 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 tohmac.Equalto avoid timing leaks. - Returned
401when an authenticated user lacked the role; that's403.
❓ 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)¶
-
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. -
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)