Stage 7 · Master
Phase 4 — Authentication Service
JWT
Design access-token claims and a key-rotation scheme so API Gateway can verify a request's identity without ever calling back into auth-service, and without trusting an attacker-chosen algorithm.
An Access Token Is a Signed Claim, Not a Session Lookup
Every other service in this platform — user-service, organization-service, and eventually API Gateway — needs to answer 'who is calling, and which organization are they acting within' on every request, often thousands of times a second. A database round trip to auth-service for each check would make auth-service a bottleneck and a single point of failure for the entire platform. A JSON Web Token flips this: auth-service signs a small claim set once at login, and any service holding the corresponding public key can verify that signature locally, in microseconds, with zero network calls.
Access tokens expire in 15 minutes. Normal signing-key rotation retains the old public key for that TTL, so healthy sessions continue uninterrupted. Immediate logout adds a short-lived Redis denylist entry for one jti; emergency removal of a compromised public key revokes the entire cohort signed by that key. Refresh-family revocation separately prevents renewal.
What Goes in the Claim Set — and What Deliberately Does Not
- sub — the user's UUID, the platform-wide identity anchor
- org_id — the organization this token is scoped to; a user with three memberships gets a fresh token per organization switch, not one token listing all three
- role — the caller's membership role within org_id (resident, staff, board_member, org_admin), read directly by RBAC middleware without a database hit
- is_platform_admin — a separate boolean, issued only for platform staff, checked explicitly wherever a cross-tenant operation is genuinely required
- jti — a random token identifier, used only by the Redis denylist for immediate logout (session-management lesson), never as a lookup key for normal verification
- kid (in the header, not the claim body) — identifies which signing key produced this token, enabling rotation without invalidating tokens already in flight
Email, display name, and anything that can change without an obvious security implication never go in the token. A user changing their email should not require re-authenticating everywhere just because a stale claim would otherwise be served for up to 15 minutes — those fields are looked up from user-service by services that display them, not trusted from the token.
The Algorithm Allowlist Is Not Optional
golang-jwt/jwt/v5's parser accepts an algorithm the caller specifies in the token header unless the verifying code explicitly restricts it. A well-known class of JWT vulnerabilities — 'alg confusion' — exploits this: an attacker takes a token signed with RS256 (asymmetric, public key only lets you verify, not sign) and resubmits it with the header changed to HS256 (symmetric), then signs it using the RS256 public key as if it were an HS256 shared secret. If the verifier doesn't pin the expected algorithm, it happily treats the public key as a valid HMAC secret and accepts a forged token.
package token
import (
"crypto/ed25519"
"fmt"
"github.com/golang-jwt/jwt/v5"
)
// Claims is this platform's closed set of access-token fields. Nothing
// beyond identity and authorization scope is ever embedded here.
type Claims struct {
jwt.RegisteredClaims
OrgID string `json:"org_id"`
Role string `json:"role"`
IsPlatformAdmin bool `json:"is_platform_admin,omitempty"`
}
// KeySet holds the currently active signing key plus any keys still valid
// for verification during a rotation window (security-best-practices lesson).
type KeySet struct {
ActiveKID string
SigningKey ed25519.PrivateKey
PublicKeys map[string]ed25519.PublicKey // kid -> public key, all keys still accepted for verification
}
const accessTokenTTL = 15 * time.Minute
func Issue(keys KeySet, userID, orgID, role string, isPlatformAdmin bool) (string, error) {
now := time.Now()
claims := Claims{
RegisteredClaims: jwt.RegisteredClaims{
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(accessTokenTTL)),
ID: newJTI(),
},
OrgID: orgID,
Role: role,
IsPlatformAdmin: isPlatformAdmin,
}
tok := jwt.NewWithClaims(jwt.SigningMethodEdDSA, claims)
tok.Header["kid"] = keys.ActiveKID
return tok.SignedString(keys.SigningKey)
}
// Verify parses raw, pinning the accepted algorithm to EdDSA only — an
// attacker cannot substitute HS256 or "none" and have it accepted, which is
// what closes the classic alg-confusion vulnerability class.
func Verify(keys KeySet, raw string) (*Claims, error) {
claims := &Claims{}
parsed, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (interface{}, error) {
kid, _ := t.Header["kid"].(string)
pub, ok := keys.PublicKeys[kid]
if !ok {
return nil, fmt.Errorf("unknown signing key %q", kid)
}
return pub, nil
}, jwt.WithValidMethods([]string{jwt.SigningMethodEdDSA.Alg()}))
if err != nil {
return nil, err
}
if !parsed.Valid {
return nil, fmt.Errorf("token is not valid")
}
return claims, nil
}jwt.WithValidMethods is the line that actually prevents alg confusion: the parser rejects any token whose header algorithm is not exactly EdDSA before the keyfunc is even consulted. Ed25519 is chosen over RS256 here for smaller signatures and faster verification at Gateway scale; either is alg-confusion-safe as long as the allowlist is enforced.
kid Makes Rotation a Header Lookup, Not a Flag Day
Round-Tripping a Token Without a Running Server
Applied exercise
Reject a forged 'none' algorithm token
A penetration test report claims your endpoint accepts a token with its header algorithm changed to 'none' and its signature stripped entirely.
- Explain, referencing jwt.WithValidMethods, why this specific attack fails against the Verify function above.
- Write the table-driven test case you would add to internal/token to permanently guard against a regression here.
- State what change to Verify (if any) would be needed if the platform ever had to accept tokens from a second, RS256-signing legacy system during a migration.
Deliverable
A written explanation plus a Go test function testing the 'none' algorithm case.
Completion checks
- The explanation correctly identifies that WithValidMethods rejects the token before the keyfunc callback ever runs.
- The test case asserts Verify returns a non-nil error for a hand-crafted 'alg: none' token.
What specifically prevents an 'alg confusion' attack against the Verify function shown in this lesson?
Mark this lesson complete to store local progress and unlock a cleaner resume path the next time you visit.