JWT Done Right, Signing, Verifying, Rotating Keys
TL;DR — Sign with RS256 or ES256 (asymmetric); never
none. Always verify signature + expiry + audience + issuer. Short lifetimes (15 min). Rotate signing keys via JWKS endpoint. Never put sensitive data in payload (it’s just base64).
After the threat model, the most-misused token format. JWT (JSON Web Token) is a signed (sometimes encrypted) JSON payload used for authentication. Misuses are everywhere; the right pattern is straightforward.
What JWT is
header.payload.signature
Three base64url-encoded segments separated by dots.
Header (algorithm + key ID):
{"alg": "RS256", "kid": "key-2022-11"}
Payload (claims):
{
"sub": "user-42",
"iss": "https://auth.example.com",
"aud": "api.example.com",
"exp": 1667568000,
"iat": 1667567100
}
Signature: HMAC or RSA/ECDSA signature over header.payload.
The signature proves the token was issued by someone with the signing key. The payload is base64-decoded plain JSON — readable by anyone with the token.
Algorithm choice
RS256 (RSA 2048+): asymmetric. Public/private key pair. Signers have the private key; verifiers (your API) have the public key. Recommended.
ES256 (ECDSA P-256): asymmetric, smaller signatures, faster verification. Equally recommended.
HS256 (HMAC-SHA256): symmetric. Same secret signs and verifies. Use only if signer and verifier are the same service. Cannot share secret with third parties safely.
alg: none: disable signature. Always reject. Documented attack from 2015; some libraries still don’t reject by default.
Rule: prefer RS256/ES256. HS256 only for internal-only tokens where the secret never leaves your auth service.
The verification checklist
Every JWT verification MUST check:
- Signature — over header.payload using the expected algorithm + key
alg— matches what you expect (not whatever the token claims)exp— not expirediss(issuer) — matches your auth serveraud(audience) — matches your APInbf(not before) — if present, current time is past it
Skipping any opens an attack. Library defaults sometimes don’t enforce all of these. Verify explicitly:
// Go example with golang-jwt/jwt/v4
token, err := jwt.ParseWithClaims(tokenString, &claims,
func(token *jwt.Token) (interface{}, error) {
// Verify alg
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return publicKey, nil
},
jwt.WithAudience("api.example.com"),
jwt.WithIssuer("https://auth.example.com"),
jwt.WithValidMethods([]string{"RS256"}),
)
The WithValidMethods is critical. Without it, an attacker can submit a token signed with HS256 using the public key as the HMAC secret — and some libraries accept it.
Lifetime
Short. 5-15 minutes for access tokens.
The reason: revocation is hard with JWTs. If an access token leaks, you can’t easily kill it before expiry. Short expiry limits damage.
Use refresh tokens (covered Nov 9) to renew without forcing reauth.
Long-lived JWTs (24h+) for direct API access are a smell. Either rotate frequently or use opaque tokens with server-side revocation.
Key rotation via JWKS
Hardcoded public keys in the verifier work but require redeploy to rotate. The right pattern: JWKS (JSON Web Key Set) endpoint.
Auth server exposes:
GET https://auth.example.com/.well-known/jwks.json
Returns:
{
"keys": [
{"kid": "key-2022-11", "kty": "RSA", "n": "...", "e": "AQAB"},
{"kid": "key-2022-10", "kty": "RSA", "n": "...", "e": "AQAB"}
]
}
Multiple keys, identified by kid. JWT header includes kid; verifier looks it up.
Rotation:
- Generate new key
- Add to JWKS (both old and new present)
- Switch signing to new key
- Wait until all tokens signed with old key have expired (max lifetime)
- Remove old key from JWKS
Verifiers cache JWKS with TTL (~1 hour). New keys propagate within an hour without redeploy.
What NOT to put in payload
JWT payload is base64-decoded JSON. Readable by anyone with the token. Don’t include:
- Passwords (obvious)
- Email + name combined (PII; can be encrypted JWT but rarely is)
- Credit card data (ever)
- Long-lived secrets
- Plaintext API keys
Include:
- User ID (opaque)
- Roles / scopes
- Standard claims (iss, aud, exp, iat, nbf, jti)
- Maybe email if it’s not sensitive in your context
When in doubt: store the data server-side, put only the user ID in the JWT.
A complete signing implementation
Signer (auth server, Go):
type Claims struct {
UserID string `json:"sub"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
func issueAccessToken(userID string, roles []string) (string, error) {
claims := Claims{
UserID: userID,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
Issuer: "https://auth.example.com",
Audience: []string{"api.example.com"},
ExpiresAt: jwt.NewNumericDate(time.Now().Add(15 * time.Minute)),
IssuedAt: jwt.NewNumericDate(time.Now()),
NotBefore: jwt.NewNumericDate(time.Now()),
ID: uuid.New().String(),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = currentKeyID
return token.SignedString(privateKey)
}
Verifier (API service):
func parseAccessToken(tokenString string) (*Claims, error) {
var claims Claims
_, err := jwt.ParseWithClaims(tokenString, &claims,
func(token *jwt.Token) (interface{}, error) {
kid, ok := token.Header["kid"].(string)
if !ok {
return nil, errors.New("missing kid")
}
key, ok := jwksCache.Get(kid)
if !ok {
return nil, errors.New("unknown kid")
}
return key, nil
},
jwt.WithIssuer("https://auth.example.com"),
jwt.WithAudience("api.example.com"),
jwt.WithValidMethods([]string{"RS256"}),
jwt.WithExpirationRequired(),
)
if err != nil {
return nil, fmt.Errorf("invalid jwt: %w", err)
}
return &claims, nil
}
jwksCache.Get(kid) fetches public keys from the auth server’s JWKS endpoint, cached.
Common Pitfalls
alg: none. Configure libraries to reject. Test it.
Public key used as HMAC secret. RS256 token validated with HS256 using public key. WithValidMethods prevents.
No expiry. Or expiry years in future. Set short; refresh.
No aud check. Token issued for service A accepted by service B.
Storing JWTs in localStorage. XSS-exfiltrable. Use httpOnly cookies (with concerns about CSRF — Nov 23).
Sensitive data in payload. It’s not encrypted. Just signed.
Long-lived tokens with no revocation. Compromised tokens stay valid for days.
Wrapping Up
RS256/ES256 + verify all claims + short expiry + JWKS rotation = JWT done right. Monday: why JWT for sessions is usually the wrong choice.