43.6 JWT Handling: Parsing and Validating Tokens Safely
Alright, let’s talk about JWTs. You’ve probably seen these things everywhere, the bearer tokens that look like a string of gibberish separated by dots. They’re a decent standard, but oh boy, the number of ways you can shoot yourself in the foot with them is truly impressive. I’ve seen more production fires started by bad JWT handling than by a toddler with a flamethrower. So let’s do it right.
First, a brutal truth: you are not just “parsing” a JWT. You are validating it. Any library that just decodes that thing and hands you back a JSON object without so much as a “how do you do?” is a trap. Treat it like a suspect package. You must verify its contents, its authenticity, and its expiration before you even think about trusting what’s inside.
The Absolute Non-Negotiables: Your Validation Checklist
Before you even glance at the claims inside the token, you must check these things. Every single time. No excuses.
- Signature: Is it signed by someone I trust? This is the big one. It prevents tampering.
- Algorithm: Did the sender use the algorithm I expected them to use? (We’ll get to the horror story behind this one in a minute).
- Expiry (
exp): Is this token still alive, or has it gone to the great auth server in the sky? - Not Before (
nbf): Has this token even become valid yet? (Less common, but you should check it if it’s there). - Audience (
aud): Was this token issued for my service specifically, or for some other app? - Issuer (
iss): Did it come from the authorization server I expect?
Miss one, and you’ve potentially left a gaping security hole. Let’s see what this looks like in code. We’ll use the excellent github.com/golang-jwt/jwt/v5 library (note the v5 — please don’t use the old github.com/dgrijalva/jwt-go; it’s archived and not maintained).
import (
"fmt"
"log"
"time"
"github.com/golang-jwt/jwt/v5"
)
// Your public key for asymmetric signing (e.g., RSA). Load this from a secure location,
// NOT hardcoded in your source code. Think environment variables or a secrets manager.
var publicKey = []byte(`-----BEGIN PUBLIC KEY-----...`)
func validateJWT(tokenString string) (*jwt.Token, error) {
// Parse the token. KeyFunc is where the magic happens.
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// 1. CRITICAL: Check the signing method. This prevents the "alg: none" attack.
if _, ok := token.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
// 2. Return the key for verification. The library will use this to validate the sig.
return jwt.ParseRSAPublicKeyFromPEM(publicKey)
})
if err != nil {
return nil, fmt.Errorf("validation failed: %w", err)
}
// 3. Check if the token is actually valid based on the standard claims.
// The library does basic checks like 'exp' and 'nbf' for us if the claims
// implement jwt.ClaimsValidator, which the standard MapClaims and RegisteredClaims do.
if !token.Valid {
return nil, fmt.Errorf("token is invalid")
}
// 4. Now, let's do our own specific business logic checks.
claims, ok := token.Claims.(jwt.MapClaims)
if !ok {
return nil, fmt.Errorf("could not parse claims")
}
// Check the audience. Your API's identifier.
if !claims.VerifyAudience("my-api-service", true) {
return nil, fmt.Errorf("invalid audience")
}
// Check the issuer. The URL of your auth server.
if !claims.VerifyIssuer("https://my-auth-server.com", true) {
return nil, fmt.Errorf("invalid issuer")
}
// You can add any other custom claim checks here.
return token, nil
}
The Algorithm Nightmare You Must Avoid
Here’s the thing that keeps security engineers up at night. The original JWT spec was… let’s say “overly flexible.” A token header could say {"alg": "none"} and some libraries would just go, “Oh, okay! No signature needed! Here are your claims, sir!” This is, and I cannot stress this enough, catastrophically stupid.
The jwt.Parse function forces you to confront this by requiring you to specify your expected signing method in the KeyFunc. In the code above, we explicitly check for *jwt.SigningMethodRSA. If a token waltzes in claiming to use HS256 (a symmetric algorithm) or, heaven forbid, none, the KeyFunc will return an error and the parsing will fail immediately. This is the correct behavior. You must tell the library what algorithm you expect, and it must reject all others.
Never Trust, Always Verify
This can’t be said enough. The entire point of a JWT is that it’s a verifiable self-contained token. You don’t need to call the auth server to check it every time (that’s the benefit), but that means the onus is entirely on you to verify it correctly. The moment you skip a check because “it’s just a dev environment” or “it’s for a non-critical endpoint,” you’re writing a future vulnerability.
Handling Claims Like a Pro
The library’s token.Valid is a good first step, but it’s just the baseline. Always, always verify the aud and iss claims yourself. Why? Because the meaning of these strings is specific to your application. The library can’t know that your API is called “my-api-service”; you have to tell it. Not checking aud is a common mistake that can allow a token generated for a different service to access yours.
Also, remember that the exp and nbf claims are Unix timestamps (seconds since epoch). The library checks these against the current time, but if you’re doing time-dependent logic, be cautious about clock skew between your servers and the auth server. A little leeway (a few seconds) is sometimes built into libraries, but it’s something to be aware of.
Finally, when you access custom claims, do it safely. Don’t just assume a claim exists and is the right type.
// Safe claim access
userID, ok := claims["user_id"].(string)
if !ok {
return nil, fmt.Errorf("user_id claim is missing or not a string")
}
// Check for a role in a custom array claim
if roles, ok := claims["roles"].([]interface{}); ok {
for _, r := range roles {
if role, ok := r.(string); ok {
// Do something with role
}
}
}
JWTs are powerful tools, but they demand respect and rigorous validation. Do it right, and you’ve got a solid stateless auth system. Get lazy, and you’re just waiting for the breach. Now go forth and validate.