JWTs are widely misused. Teams reach for them by default because every tutorial shows them, not because they are the right tool for the job. Before writing any code, you need to understand what JWTs actually are and what they are not.
A JWT is a signed token containing claims. The signature lets the holder verify the token was issued by someone with the signing key. It does not provide encryption (unless you use JWE). It is not a session: you cannot invalidate it server-side without maintaining state, which defeats the “stateless” pitch. It is not inherently secure: the alg: none attack and HS256-with-shared-secrets-across-services have caused real production incidents.
For browser-based web applications, cookie sessions with server-side session storage are often the better choice. JWTs shine in service-to-service authentication and API access where statelessness is a genuine design requirement.
HS256 vs RS256#
flowchart LR
subgraph HS256
A[Service A] -->|shared secret| B[Service B]
A -->|shared secret| C[Service C]
end
subgraph RS256
I[Issuer\nprivate key] -->|sign| T[Token]
T -->|verify with public key| D[Service D]
T -->|verify with public key| E[Service E]
I -->|JWKS endpoint| D
I -->|JWKS endpoint| E
end
HS256 (HMAC-SHA256) uses a single secret key for both signing and verification. Any service that verifies the token must have the secret, which means the secret must be distributed to all verifiers. A compromise of any service compromises the signing capability.
RS256 (RSA-SHA256) uses an asymmetric key pair. The issuer signs with the private key; verifiers only need the public key. The private key never leaves the issuer. You can publish the public key at a JWKS endpoint and let other services fetch it.
- Single-service authentication where the issuer and verifier are the same service.
- Internal monolith or API gateway that both issues and validates tokens.
- The secret is stored in a secrets manager (not hardcoded or in env vars).
Never use HS256 when multiple distinct services need to verify tokens.
- Multiple independent services need to verify tokens without sharing a secret.
- You operate an authorization server (OAuth2, OIDC) issuing tokens to third parties.
- You want a public JWKS endpoint so verifiers can rotate public keys automatically.
- Compliance requirements demand non-repudiation (only the private key holder could have signed).
HS256: Correct Key Loading and Validation#
package auth
import (
"errors"
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// Claims holds our custom payload alongside standard registered claims.
type Claims struct {
UserID string `json:"uid"`
Roles []string `json:"roles"`
jwt.RegisteredClaims
}
type HS256Issuer struct {
secret []byte
issuer string
audience []string
ttl time.Duration
}
// NewHS256Issuer loads the secret from an environment variable.
// Never accept the secret as a string argument from a caller -- it invites hardcoding.
func NewHS256Issuer(envVar, issuer string, audience []string, ttl time.Duration) (*HS256Issuer, error) {
secret := os.Getenv(envVar)
if len(secret) < 32 {
return nil, fmt.Errorf("env var %q must be at least 32 bytes for HS256", envVar)
}
return &HS256Issuer{
secret: []byte(secret),
issuer: issuer,
audience: audience,
ttl: ttl,
}, nil
}
func (i *HS256Issuer) Issue(userID string, roles []string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(), // jti: unique per token, used for revocation
Issuer: i.issuer,
Audience: i.audience,
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString(i.secret)
}
func (i *HS256Issuer) Validate(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(
tokenString,
&Claims{},
func(t *jwt.Token) (interface{}, error) {
// Always check the algorithm. An attacker can craft a token with
// alg: none or alg: RS256 and pass the public key as the HMAC secret.
if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return i.secret, nil
},
jwt.WithIssuer(i.issuer),
jwt.WithAudience(i.audience[0]),
jwt.WithExpirationRequired(),
)
if err != nil {
return nil, fmt.Errorf("validate token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, errors.New("invalid token claims")
}
return claims, nil
}The algorithm check in the Keyfunc is not optional. The alg: none vulnerability allows an attacker to strip the signature entirely and construct an arbitrary payload that some implementations accept. The jwt.WithExpirationRequired() option ensures tokens without an exp claim are rejected, which they should be unless you have a specific reason.
RS256: RSA Key Pair, Signing, and Verification#
package auth
import (
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/pem"
"fmt"
"os"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/google/uuid"
)
// GenerateRSAKeyPair generates a 2048-bit RSA key pair and writes PEM files.
// Run this once during setup; store the private key in a secrets manager.
func GenerateRSAKeyPair(privateKeyPath, publicKeyPath string) error {
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return fmt.Errorf("generate rsa key: %w", err)
}
privPEM := pem.EncodeToMemory(&pem.Block{
Type: "RSA PRIVATE KEY",
Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
})
if err := os.WriteFile(privateKeyPath, privPEM, 0600); err != nil {
return fmt.Errorf("write private key: %w", err)
}
pubDER, err := x509.MarshalPKIXPublicKey(&privateKey.PublicKey)
if err != nil {
return fmt.Errorf("marshal public key: %w", err)
}
pubPEM := pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})
return os.WriteFile(publicKeyPath, pubPEM, 0644)
}
type RS256Issuer struct {
privateKey *rsa.PrivateKey
publicKey *rsa.PublicKey
keyID string // kid claim, used in JWKS
issuer string
ttl time.Duration
}
func NewRS256Issuer(privateKeyPath, keyID, issuer string, ttl time.Duration) (*RS256Issuer, error) {
pemData, err := os.ReadFile(privateKeyPath)
if err != nil {
return nil, fmt.Errorf("read private key: %w", err)
}
block, _ := pem.Decode(pemData)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}
privateKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("parse private key: %w", err)
}
return &RS256Issuer{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
keyID: keyID,
issuer: issuer,
ttl: ttl,
}, nil
}
func (i *RS256Issuer) Issue(userID string, roles []string) (string, error) {
now := time.Now()
claims := Claims{
UserID: userID,
Roles: roles,
RegisteredClaims: jwt.RegisteredClaims{
ID: uuid.NewString(),
Issuer: i.issuer,
Subject: userID,
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(i.ttl)),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
token.Header["kid"] = i.keyID // include key ID for JWKS lookup
return token.SignedString(i.privateKey)
}
func (i *RS256Issuer) Validate(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(
tokenString,
&Claims{},
func(t *jwt.Token) (interface{}, error) {
if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
}
return i.publicKey, nil
},
jwt.WithIssuer(i.issuer),
jwt.WithExpirationRequired(),
)
if err != nil {
return nil, fmt.Errorf("validate rs256 token: %w", err)
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, fmt.Errorf("invalid token claims")
}
return claims, nil
}Standard Claims: What Each Is For#
| Claim | Name | Purpose |
|---|---|---|
iss | Issuer | Who issued the token. Validate this to prevent token substitution attacks. |
sub | Subject | The principal the token represents (usually user ID). |
aud | Audience | Which service the token is intended for. Validate to prevent token reuse across services. |
exp | Expiration | Unix timestamp after which the token is invalid. Always set this. |
iat | Issued At | When the token was issued. Useful for detecting tokens issued before a key rotation. |
jti | JWT ID | Unique identifier per token. Required for revocation (blocklist by JTI). |
The aud claim is critical in multi-service architectures. If service A and service B both use the same issuer, a token issued for service A must not be accepted by service B. Validate the aud claim on every verification.
Validation Middleware for net/http#
package auth
import (
"context"
"errors"
"net/http"
"strings"
)
type contextKey string
const claimsKey contextKey = "jwt_claims"
// Validator is implemented by both HS256Issuer and RS256Issuer.
type Validator interface {
Validate(tokenString string) (*Claims, error)
}
// Middleware extracts the Bearer token, validates it, and injects claims into context.
// On failure it returns 401 and does not call the next handler.
func Middleware(v Validator) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
if authHeader == "" {
http.Error(w, "missing Authorization header", http.StatusUnauthorized)
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || !strings.EqualFold(parts[0], "Bearer") {
http.Error(w, "invalid Authorization header format", http.StatusUnauthorized)
return
}
claims, err := v.Validate(parts[1])
if err != nil {
// Do not leak validation error details to the client.
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// ClaimsFromContext retrieves validated claims from the request context.
func ClaimsFromContext(ctx context.Context) (*Claims, bool) {
claims, ok := ctx.Value(claimsKey).(*Claims)
return claims, ok
}Usage with the standard net/http mux:
package main
import (
"encoding/json"
"net/http"
"os"
"time"
"yourmodule/internal/auth"
)
func main() {
issuer, err := auth.NewHS256Issuer("JWT_SECRET", "auth-service", []string{"api"}, time.Hour)
if err != nil {
panic(err)
}
mux := http.NewServeMux()
// Public endpoint: issue token after credential verification.
mux.HandleFunc("POST /auth/token", func(w http.ResponseWriter, r *http.Request) {
// Verify credentials... (omitted)
token, err := issuer.Issue("user-123", []string{"reader"})
if err != nil {
http.Error(w, "internal error", http.StatusInternalServerError)
return
}
json.NewEncoder(w).Encode(map[string]string{"token": token})
})
// Protected endpoint: middleware validates the token.
protected := http.NewServeMux()
protected.HandleFunc("GET /api/profile", func(w http.ResponseWriter, r *http.Request) {
claims, _ := auth.ClaimsFromContext(r.Context())
json.NewEncoder(w).Encode(map[string]string{"user_id": claims.UserID})
})
mux.Handle("/api/", auth.Middleware(issuer)(protected))
http.ListenAndServe(":8080", mux)
}Token Revocation with Redis#
JWTs are stateless by design; revocation requires adding state. The standard approach is a JTI blocklist: store the jti of revoked tokens in Redis with a TTL matching the token’s exp. Tokens expire from Redis automatically when they would have expired anyway.
package auth
import (
"context"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
type Blocklist struct {
rdb *redis.Client
}
func NewBlocklist(rdb *redis.Client) *Blocklist {
return &Blocklist{rdb: rdb}
}
// Revoke adds a token's JTI to the blocklist until its expiration.
func (b *Blocklist) Revoke(ctx context.Context, claims *Claims) error {
ttl := time.Until(claims.ExpiresAt.Time)
if ttl <= 0 {
return nil // already expired, no need to store
}
key := fmt.Sprintf("blocklist:jti:%s", claims.ID)
return b.rdb.Set(ctx, key, "1", ttl).Err()
}
// IsRevoked returns true if the JTI is in the blocklist.
func (b *Blocklist) IsRevoked(ctx context.Context, jti string) (bool, error) {
exists, err := b.rdb.Exists(ctx, fmt.Sprintf("blocklist:jti:%s", jti)).Result()
if err != nil {
return false, fmt.Errorf("blocklist check: %w", err)
}
return exists > 0, nil
}Extend the middleware to check the blocklist:
package auth
import (
"context"
"net/http"
"strings"
)
func MiddlewareWithRevocation(v Validator, bl *Blocklist) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
authHeader := r.Header.Get("Authorization")
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
claims, err := v.Validate(parts[1])
if err != nil {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
revoked, err := bl.IsRevoked(r.Context(), claims.ID)
if err != nil || revoked {
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}The blocklist check adds one Redis roundtrip per request. For high-traffic APIs, consider a local in-memory bloom filter as a first-pass check, falling back to Redis only for potential positives. This reduces Redis load at the cost of a small false-positive rate (which always results in a full Redis check, not a false revocation).
RS256 JWKS Endpoint#
A JWKS (JSON Web Key Set) endpoint lets other services fetch the public key without manual distribution. This is the standard pattern in OAuth2 and OIDC.
package auth
import (
"crypto/rsa"
"encoding/base64"
"encoding/json"
"math/big"
"net/http"
)
type jwk struct {
Kty string `json:"kty"`
Use string `json:"use"`
Kid string `json:"kid"`
Alg string `json:"alg"`
N string `json:"n"` // base64url-encoded modulus
E string `json:"e"` // base64url-encoded exponent
}
type jwks struct {
Keys []jwk `json:"keys"`
}
// JWKSHandler serves the public key in JWKS format.
// Mount this at /.well-known/jwks.json.
func JWKSHandler(publicKey *rsa.PublicKey, keyID string) http.HandlerFunc {
key := jwk{
Kty: "RSA",
Use: "sig",
Kid: keyID,
Alg: "RS256",
N: base64.RawURLEncoding.EncodeToString(publicKey.N.Bytes()),
E: base64.RawURLEncoding.EncodeToString(big.NewInt(int64(publicKey.E)).Bytes()),
}
body, _ := json.Marshal(jwks{Keys: []jwk{key}})
return func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Cache-Control", "public, max-age=3600")
w.Write(body)
}
}Other services can use jwx or fetch the JWKS and verify tokens without knowing the private key:
package auth
import (
"context"
"fmt"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/lestrrat-go/jwx/v2/jwt"
)
type RemoteVerifier struct {
cache *jwk.Cache
jwksURL string
}
func NewRemoteVerifier(ctx context.Context, jwksURL string) (*RemoteVerifier, error) {
cache := jwk.NewCache(ctx)
if err := cache.Register(jwksURL); err != nil {
return nil, fmt.Errorf("register jwks: %w", err)
}
return &RemoteVerifier{cache: cache, jwksURL: jwksURL}, nil
}
func (v *RemoteVerifier) Verify(ctx context.Context, tokenBytes []byte) (jwt.Token, error) {
keySet, err := v.cache.Get(ctx, v.jwksURL)
if err != nil {
return nil, fmt.Errorf("fetch jwks: %w", err)
}
return jwt.Parse(tokenBytes, jwt.WithKeySet(keySet), jwt.WithValidate(true))
}Common Mistakes#
HS256 with shared secrets in microservices
Accepting tokens without exp validation
golang-jwt/jwt library does not enforce expiration by default in all versions. Always pass jwt.WithExpirationRequired() to your parser. Audit every token validation path in your codebase.Using JWT for web application session state
localStorage are accessible to JavaScript and vulnerable to XSS. Cookie sessions with HttpOnly, Secure, and SameSite=Strict are safer for web apps. JWTs make sense for API clients (mobile apps, CLIs, service-to-service) where cookies are not applicable.Hardcoding secrets or loading them from plaintext env files
Not validating the aud claim
aud, a token issued for service A is also valid for service B. This violates the principle of least privilege and can be exploited if service B has higher privileges than service A.If you want to go deeper on any of this, I offer 1:1 coaching sessions for engineers working on AI integration, cloud architecture, and platform engineering. Book a session (50 EUR / 60 min) or reach out at manuel.fedele+website@gmail.com.