OAuth2 and OpenID Connect: Production Security Patterns
Picture a session token sitting in
localStorage, readable by any JavaScript running on the page — no expiry, no binding to the original IP or user agent. Nothing about this is exotic: XSS remains one of the most frequently reported web vulnerabilities, with more than 30,000 CVEs[1], and an injected ad script, a compromised npm package, or a browser extension with overly broad permissions is all it takes to silently exfiltrate a token stored this way.
An attacker exploiting it doesn't need passwords. They don't need to bypass two-factor authentication. They need JavaScript to run and localStorage.getItem('access_token') to return a value.
OAuth2 without the right token lifecycle controls — short TTLs, refresh token rotation, revocation — is a persistent XSS vulnerability waiting to happen. The four flows cover different client types (browser, server, device); PKCE prevents code interception; refresh token rotation detects theft; instant revocation requires opaque tokens. Tokens must always be httpOnly/server-side, never in JavaScript-accessible storage.
- Store tokens in httpOnly cookies, never localStorage or sessionStorage
- Use refresh token rotation with family tracking to detect token theft immediately
- Deploy short-lived access tokens (15–60 min) + revocation endpoints for account lockouts
The Four OAuth2 Flows
Source[2]| Flow | When to use | Client type | Has refresh token? |
|---|---|---|---|
| Authorization Code + PKCE | Web apps, SPAs, mobile apps. User login with redirect. | Public (can't store secret) | Yes |
| Client Credentials | Backend service-to-service. No user involved. | Confidential (has secret) | No — token lasts entire session |
| Device Code | Smart TV, IoT devices. User approves on separate device. | Public | Yes |
| Refresh Token Rotation | Keep access tokens short-lived, rotate on every refresh. All flows. | Both | Yes, rotated per request |
Skip these: Implicit and Resource Owner Password Credential (both deprecated in the OAuth 2.1 draft), and any flow that doesn't use PKCE for public clients.
sequenceDiagram
participant U as User (Browser)
participant App as Your App
participant AS as Auth Server
participant API as Resource API
U->>App: Click "Sign in"
App->>App: Generate code_verifier + code_challenge (PKCE)
App->>AS: GET /authorize?response_type=code&code_challenge=...
AS->>U: Show login page
U->>AS: Enter credentials
AS->>App: Redirect with authorization code
App->>AS: POST /token (code + code_verifier)
AS->>App: access_token + refresh_token + id_token
App->>API: GET /resource (Bearer access_token)
API->>App: Protected data
Flow 1: Authorization Code + PKCE (The Gold Standard)
Use for: Web apps, SPAs, mobile apps. The user clicks "Sign in," redirects to the auth server[2], and the app exchanges an authorization code for tokens.
PKCE (Proof Key for Code Exchange)[3] prevents authorization code interception attacks: a malicious app on the same device intercepts the authorization code. Without PKCE, that code can be exchanged for tokens. With PKCE, only the original requester (who has the code_verifier) can exchange it.
package oauth2handler
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"fmt"
"net/http"
"time"
"golang.org/x/oauth2"
)
type Handler struct {
config *oauth2.Config
}
// generateCodeVerifier: 32 random bytes, base64-URL-encoded (43 chars).
// Since Go 1.24, rand.Read never returns an error (it crashes the process
// instead); the check matters on older toolchains, where a failed read
// must fail the request rather than yield a partly zero-filled verifier.
func generateCodeVerifier() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", fmt.Errorf("generate code verifier: %w", err)
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// codeChallenge: SHA256(code_verifier), base64-URL-encoded.
func codeChallenge(verifier string) string {
h := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(h[:])
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
// Generate PKCE verifier, store in httpOnly cookie.
codeVerifier, err := generateCodeVerifier()
if err != nil {
http.Error(w, "failed to start login", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: "code_verifier",
Value: codeVerifier,
MaxAge: 600, // 10 min expiry (code must be used quickly)
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
// Redirect to auth server with code_challenge.
authURL := h.config.AuthCodeURL(
generateState(), // CSRF token in URL
oauth2.SetAuthURLParam("code_challenge", codeChallenge(codeVerifier)),
oauth2.SetAuthURLParam("code_challenge_method", "S256"),
)
http.Redirect(w, r, authURL, http.StatusFound)
}
func (h *Handler) Callback(w http.ResponseWriter, r *http.Request) {
// Extract code_verifier from httpOnly cookie.
cookie, err := r.Cookie("code_verifier")
if err != nil || cookie.Value == "" {
http.Error(w, "missing PKCE verifier", http.StatusBadRequest)
return
}
// Exchange authorization code + PKCE verifier for tokens.
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
token, err := h.config.Exchange(ctx, r.URL.Query().Get("code"),
oauth2.SetAuthURLParam("code_verifier", cookie.Value),
)
if err != nil {
http.Error(w, "token exchange failed", http.StatusInternalServerError)
return
}
// Store access_token + refresh_token in httpOnly cookies.
setTokenCookies(w, token)
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
}
func setTokenCookies(w http.ResponseWriter, token *oauth2.Token) {
// Access token: short-lived (1 hour default).
http.SetCookie(w, &http.Cookie{
Name: "access_token",
Value: token.AccessToken,
MaxAge: 3600,
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
// Refresh token: long-lived (30 days, rotated on use).
http.SetCookie(w, &http.Cookie{
Name: "refresh_token",
Value: token.RefreshToken,
MaxAge: 2592000, // 30 days
HttpOnly: true,
Secure: true,
SameSite: http.SameSiteStrictMode,
})
}Critical: Store tokens in httpOnly cookies. Never in localStorage or URL parameters. XSS cannot read httpOnly cookies; localStorage is world-readable to any JavaScript on the page.
Flow 2: Refresh Token Rotation (With Theft Detection)
The theft-detection state machine in one picture — replay of any rotated token revokes the entire family:[2]
graph TD
Start[Refresh token presented<br/>at /token endpoint] --> Lookup{Lookup token<br/>in store}
Lookup -->|not found| Reject1[Reject: invalid_grant]
Lookup -->|found| Status{Status?}
Status -->|active —<br/>first use| Rotate[Issue new access_token<br/>+ new refresh_token<br/>mark old as ROTATED]
Status -->|rotated —<br/>presented again!| Theft[THEFT DETECTED<br/>revoke entire family<br/>for this user + client]
Status -->|revoked| Reject2[Reject: invalid_grant]
Theft --> Audit[Log + alert<br/>force re-login]
Rotate --> Done[Return new tokens]
Audit --> Reject3[Reject: invalid_grant]
style Theft fill:#fdd
style Audit fill:#fdd
style Done fill:#dfd
style Rotate fill:#dfd
The invariant: at any moment, at most one refresh token in the family is active. Presenting a rotated token triggers family-wide revocation on the assumption that it was stolen; a revoked token is simply rejected.
Every time the client uses a refresh token to get a new access token, issue a new refresh token and invalidate the old one. If a stolen refresh token is replayed, the system detects it: the legitimate user's next refresh attempt will fail because their old token was already used.
package tokenrotation
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"time"
)
type RefreshTokenRecord struct {
TokenHash string // SHA256(refresh_token) — never store raw tokens
FamilyID string // Links rotated tokens; theft = entire family revoked
UserID string
IssuedAt time.Time
LastUsedAt time.Time
ExpiresAt time.Time
IsRevoked bool
}
// IssueRefreshToken: create a new token + hash under a family, store, return.
// Pass an empty familyID to start a new family (fresh login); pass an existing
// family to rotate within it so reuse of any token revokes the whole chain.
func IssueRefreshToken(userID, familyID string, db *DB) (token string, record *RefreshTokenRecord, err error) {
token = generateSecureRandom(32)
tokenHash := hashToken(token)
if familyID == "" {
familyID = generateUUID()
}
record = &RefreshTokenRecord{
TokenHash: tokenHash,
FamilyID: familyID,
UserID: userID,
IssuedAt: time.Now(),
ExpiresAt: time.Now().Add(30 * 24 * time.Hour), // 30-day TTL
}
if err := db.InsertRefreshToken(record); err != nil {
return "", nil, err
}
return token, record, nil
}
// UseRefreshToken: validate + rotate. Return new tokens + detect theft.
// The user comes from the stored record: a refresh request carries only the
// token, so trusting a caller-supplied user ID would let any valid refresh
// token mint access tokens for someone else.
func UseRefreshToken(clientToken string, db *DB) (newAccessToken, newRefreshToken string, err error) {
tokenHash := hashToken(clientToken)
// Look up token by hash. Revoked rows are returned too (IsRevoked set),
// so the /revoke handler below can still find a revoked token's family.
record, err := db.GetRefreshTokenByHash(tokenHash)
if err != nil {
// A storage failure is not an invalid token: reporting invalid_grant
// here would make every client discard its token and force a re-login.
return "", "", fmt.Errorf("look up refresh token: %w", err)
}
if record == nil {
return "", "", ErrInvalidToken
}
// Token already used? (Reuse = theft detection)
if !record.LastUsedAt.IsZero() {
return "", "", revokeOnReuse(db, record.FamilyID)
}
// Revoked (logout, or its family was revoked after theft)? Without this
// check, the newest token of a revoked family would still rotate.
if record.IsRevoked {
return "", "", ErrInvalidToken
}
// Expired?
if time.Now().After(record.ExpiresAt) {
return "", "", ErrTokenExpired
}
// Mark as used with a conditional write, e.g.
// UPDATE refresh_tokens SET last_used_at = now()
// WHERE token_hash = $1 AND last_used_at IS NULL AND NOT is_revoked
// A read-then-write would let two concurrent presentations of this token
// both pass the check above and both rotate; AND NOT is_revoked closes
// the same race against a family revocation landing between the
// IsRevoked check above and this write. Zero rows updated means someone
// else used it first, or the family was just revoked — either way, fail
// like reuse. A failed write must fail the refresh, or a stolen copy
// stays replayable without tripping detection.
marked, err := db.MarkRefreshTokenUsed(tokenHash)
if err != nil {
return "", "", fmt.Errorf("persist token use: %w", err)
}
if !marked {
return "", "", revokeOnReuse(db, record.FamilyID)
}
// Issue new tokens (refresh token rotates) within the SAME family, so
// reuse of any token in the chain revokes the whole family. Passing the
// family into IssueRefreshToken persists it on the new row — patching the
// returned struct after the insert (as a previous version did) never
// reached the DB and silently broke theft detection.
newRefreshToken, _, err = IssueRefreshToken(record.UserID, record.FamilyID, db)
if err != nil {
return "", "", err
}
// Issue short-lived access token.
newAccessToken, err = issueAccessToken(record.UserID, time.Hour)
if err != nil {
return "", "", err
}
return newAccessToken, newRefreshToken, nil
}
// revokeOnReuse revokes the whole family: an attacker may hold one of its
// tokens. A failed revocation is surfaced, not swallowed — continuing would
// leave the stolen chain live while the caller believed it was dead.
func revokeOnReuse(db *DB, familyID string) error {
if err := db.RevokeTokenFamily(familyID); err != nil {
return fmt.Errorf("revoke token family after reuse: %w", err)
}
return ErrTokenReused // force the user to re-login
}
func hashToken(token string) string {
h := sha256.Sum256([]byte(token))
return hex.EncodeToString(h[:])
}Key insight: Track family_id. All tokens issued from the same original refresh are in the same family. If reuse is detected, revoke the entire family and force the user to re-login. This stops attackers cold if they steal a refresh token.
Flow 3: Client Credentials (Service-to-Service)
Use for: Backend service-to-service calls. No user involved. The client authenticates with its own credentials (client_id + client_secret) and gets an access token directly.
func (h *Handler) ClientCredentials(w http.ResponseWriter, r *http.Request) {
clientID := r.FormValue("client_id")
clientSecret := r.FormValue("client_secret")
scope := r.FormValue("scope") // e.g., "payments:read"
// Authenticate client by secret (bcrypt hash in DB).
client, err := h.db.GetClientByID(clientID)
if err != nil || bcrypt.CompareHashAndPassword([]byte(client.SecretHash), []byte(clientSecret)) != nil {
w.WriteHeader(http.StatusUnauthorized)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_client"})
return
}
// Verify requested scope is allowed for this client.
if !client.AllowedScopes.Contains(scope) {
w.WriteHeader(http.StatusForbidden)
json.NewEncoder(w).Encode(map[string]string{"error": "invalid_scope"})
return
}
// Issue access token (no refresh token for service-to-service).
accessToken, err := issueAccessToken(clientID, 1*time.Hour)
if err != nil {
w.WriteHeader(http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(map[string]interface{}{
"access_token": accessToken,
"token_type": "Bearer",
"expires_in": 3600,
})
}Client credentials grants no refresh token. The client must re-authenticate to get a new token. Use this for service-to-service calls over secure channels (VPC, mTLS). Never expose client secrets to browsers or mobile clients.
JWT vs Opaque Tokens
JWT access tokens: The token itself contains claims (user ID, scopes, expiry). Validation requires no database lookup — check the signature and you're done. Can't be revoked instantly.[4]
func ValidateJWT(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return publicKey, nil // Pre-fetch JWKS once on startup
})
if err != nil {
return nil, err
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}Opaque tokens: Random strings. Validation requires a database call to look up the token's metadata. Can be revoked instantly.
func ValidateOpaqueToken(tokenString string) (*TokenMetadata, error) {
// Database call — can be cached with TTL.
metadata, err := db.GetTokenMetadata(tokenString)
if err != nil || metadata.IsRevoked {
return nil, ErrInvalidToken
}
return metadata, nil
}When to use:
- JWT: Low-latency APIs where revocation isn't critical (product recommendations, read-only data). Short TTL + refresh token rotation mitigate the "can't revoke instantly" limitation.
- Opaque: Financial APIs, healthcare, anywhere instant lockout matters (password change, logout, account ban).
Token Revocation (RFC 7009)
Implement a /revoke endpoint per RFC 7009[5]. On logout, password change, or account compromise, revoke tokens so they stop working immediately.
// Client authentication runs in middleware; the lookups below must also
// confirm the token was issued to that authenticated client before
// revoking it (RFC 7009 §2.1) — omitted here for brevity.
func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
// token_type_hint is only a lookup hint: the server must find the token
// even when the hint is wrong or absent (RFC 7009 §2.1), so it is not
// used to decide whether a refresh-token family gets revoked.
tokenHash := hashToken(r.FormValue("token"))
// Mark revoked in DB (an unknown hash is a no-op, not an error). A
// storage failure means nothing was revoked: 503 tells the client to
// assume the token is still live and retry (RFC 7009 §2.2.1).
if err := h.db.RevokeToken(tokenHash); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
// A refresh token takes its entire family with it (the logout path;
// password change or compromise revokes every family for the user).
// A swallowed error here would return 200 OK while the rest of the
// family stayed live.
record, err := h.db.GetRefreshTokenByHash(tokenHash)
if err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
if record != nil {
if err := h.db.RevokeTokenFamily(record.FamilyID); err != nil {
w.WriteHeader(http.StatusServiceUnavailable)
return
}
}
// 200 OK, even for an unknown or already-revoked token (RFC 7009 §2.2).
w.WriteHeader(http.StatusOK)
}On logout, POST the refresh token to /revoke. On password change or admin ban, revoke all refresh token families for the user. This stops attackers' stolen tokens immediately.
Production Checklist
For all flows:
- Use Authorization Code + PKCE for public clients (SPAs, mobile, desktop)
- Store tokens in httpOnly cookies, never
localStorage - Validate
stateparameter on every callback (CSRF protection) - Access tokens: 15 minutes to 1 hour TTL maximum
- Validate redirect URIs with exact string matching (not prefix matching)
- Use
prompt=consentwhen requesting offline access (refresh tokens)
For refresh token rotation:
- Issue new refresh token on every use; invalidate the old one
- Store token hashes with SHA256, never raw tokens
- Track
family_idfor theft detection - On reuse: revoke entire family and force re-login
- Refresh token TTL: 30–90 days (longer than access token)
For token revocation:
- Implement RFC 7009
/revokeendpoint - On logout: revoke refresh token (entire family)
- On password change: revoke all refresh token families
- On account compromise: revoke all families, force re-login everywhere
For OIDC (authentication layer):
- Validate ID token signature against JWKS (fetch once, cache with TTL)
- Verify
audclaim matches yourclient_id - Verify
issclaim matches expected issuer - Use
nonceparameter to prevent ID token replay
JWKS verifier with rotation that doesn't break on key rollover
The "fetch once, cache with TTL" line in the checklist hides a failure mode: when the IdP rotates signing keys mid-cache, every token signed with the new key fails kid not found. keyfunc v3 already re-fetches the JWKS when a token names an unknown kid; the verifier below caps that refresh at one fetch per 30 seconds (the default is one per 5 minutes) and makes requests fail fast instead of queueing behind it:
package authn
import (
"context"
"errors"
"fmt"
"time"
"github.com/MicahParks/keyfunc/v3"
"github.com/golang-jwt/jwt/v5"
"golang.org/x/time/rate"
)
type JWKSVerifier struct {
jwks keyfunc.Keyfunc
expectedIss string
expectedAud string
}
func NewJWKSVerifier(ctx context.Context, jwksURL, iss, aud string) (*JWKSVerifier, error) {
// ctx bounds keyfunc's background refresh goroutine, not a single request.
// Unknown kid => keyfunc re-fetches the JWKS, gated by RefreshUnknownKID:
// at most one such refresh per 30s. RateLimitWaitMax bounds both waiting
// for that limiter AND the refresh HTTP fetch itself, so it has to cover
// a slow IdP round trip, not just limiter contention (the defaults are
// one refresh per 5 min and up to a minute for both).
jwks, err := keyfunc.NewDefaultOverrideCtx(ctx, []string{jwksURL}, keyfunc.Override{
RefreshUnknownKID: rate.NewLimiter(rate.Every(30*time.Second), 1),
RateLimitWaitMax: 5 * time.Second,
})
if err != nil {
return nil, fmt.Errorf("jwks bootstrap: %w", err)
}
return &JWKSVerifier{jwks: jwks, expectedIss: iss, expectedAud: aud}, nil
}
func (v *JWKSVerifier) Verify(ctx context.Context, raw string) (*jwt.RegisteredClaims, error) {
// In golang-jwt v5 the per-claim VerifyXXX methods were removed; audience,
// issuer and a mandatory exp are enforced by the built-in validator via
// parser options. A kid still unknown after the refresh surfaces as
// jwt.ErrTokenUnverifiable.
token, err := jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, v.jwks.KeyfuncCtx(ctx),
jwt.WithAudience(v.expectedAud),
jwt.WithIssuer(v.expectedIss),
jwt.WithExpirationRequired(),
)
if err != nil {
return nil, fmt.Errorf("jwt parse: %w", err)
}
claims, ok := token.Claims.(*jwt.RegisteredClaims)
if !ok || !token.Valid {
return nil, errors.New("invalid claims")
}
return claims, nil
}The matching tests catch the key-rotation regression — given two JWKS payloads (old key set, new key set), a token signed with the new key must verify after the on-demand refresh fires once:
func TestVerifyAfterKeyRotation(t *testing.T) {
ctx := context.Background()
// Phase 1: server publishes the old JWKS, verifier bootstraps.
srv := newJWKSServer(t, oldKeySet)
v, err := NewJWKSVerifier(ctx, srv.URL+"/jwks", "https://idp.example", "checkout-api")
if err != nil { t.Fatal(err) }
if _, err := v.Verify(ctx, signWith(oldKey, "checkout-api")); err != nil {
t.Fatalf("old key should verify: %v", err)
}
// Phase 2: IdP rotates. Server now publishes new JWKS, old key removed.
// First Verify call with a new-key-signed token MUST trigger refresh and pass.
srv.SetKeys(newKeySet)
if _, err := v.Verify(ctx, signWith(newKey, "checkout-api")); err != nil {
t.Fatalf("new key should verify after refresh: %v", err)
}
// Phase 3: confirm rate-limited — a second unknown kid within 30s must be
// rejected (fail closed) WITHOUT hitting the IdP again.
if _, err := v.Verify(ctx, signWith(strayKey, "checkout-api")); err == nil {
t.Fatal("unknown kid inside the refresh window must be rejected")
}
srv.AssertFetchCount(t, 2) // bootstrap + the one rotation-triggered refresh
}If you skip the rate-limit, a single attacker spamming garbage tokens with random kid values will turn your auth path into a DOS amplifier against the IdP.
The matching middleware that wires the verifier into a chi/mux router — extracts the bearer, verifies once per request, attaches the claims to context for downstream handlers:
func RequireJWT(v *JWKSVerifier) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
auth := r.Header.Get("Authorization")
if !strings.HasPrefix(auth, "Bearer ") {
http.Error(w, "missing bearer", http.StatusUnauthorized)
return
}
claims, err := v.Verify(r.Context(), strings.TrimPrefix(auth, "Bearer "))
if err != nil {
http.Error(w, "invalid token", http.StatusUnauthorized)
return
}
ctx := context.WithValue(r.Context(), claimsKey{}, claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}DPoP: Sender-Constrained Tokens (RFC 9449)
Bearer tokens have one structural weakness no amount of httpOnly cookies can fix: whoever holds the token can use it. A reverse proxy that logs Authorization headers, a sidecar with overly broad service-mesh permissions, or a misconfigured CDN that caches an authenticated response can all leak a token that immediately becomes a valid session for the attacker. DPoP (Demonstrating Proof-of-Possession at the Application Layer)[6] binds an access token to a client-held key pair, so a stolen token alone is useless without the matching private key.
The mechanism is straightforward. The client generates an ephemeral keypair (typically ECDSA P-256 in the browser via SubtleCrypto.generateKey), sends the public JWK to the auth server during the token exchange, and receives an access token whose cnf (confirmation) claim contains the JWK thumbprint. On every API request, the client signs a short-lived DPoP proof JWT that names the HTTP method and target URI, and submits it alongside the access token in a separate DPoP header. The resource server verifies the proof's signature, confirms the proof's ath claim is the hash of the presented access token and the thumbprint matches the token's cnf.jkt, checks freshness via the iat and jti (replay cache) claims, and only then grants access.
A request looks like this on the wire:
POST /payments HTTP/1.1
Host: api.example.com
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9...
DPoP: eyJhbGciOiJFUzI1NiIsInR5cCI6ImRwb3Arand0Iiwiandr
Ijp7Imt0eSI6IkVDIiwiY3J2IjoiUC0yNTYi...fQ.
eyJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vcGF5bWVudHMiLCJpYXQiOjE3MjU0NjEwMDAs
Imp0aSI6IjlmOGItMTIzNCIsImF0aCI6ImhQbmRrcFdINHI3RHlVZE1VcnJxRjNqREpBMDYzemYwSDZEcTJQdnpwWVUifQ.
Q2Xv5n8JwT0...
Content-Type: application/json
{"amount": 1200, "currency": "GBP"}Note the scheme switched from Bearer to DPoP in the Authorization header — that's the signal to the resource server that proof verification is mandatory. The verifier on the resource server performs four independent checks:
func VerifyDPoP(r *http.Request, accessToken, accessTokenCnf string) error {
proof := r.Header.Get("DPoP")
parsed, err := jwt.Parse(proof, func(t *jwt.Token) (interface{}, error) {
if t.Header["typ"] != "dpop+jwt" { return nil, errors.New("typ must be dpop+jwt") }
// The signing key is embedded in the proof's `jwk` header — extract it.
jwk, ok := t.Header["jwk"].(map[string]interface{})
if !ok { return nil, errors.New("missing jwk header") }
return jwkToPublicKey(jwk)
}, jwt.WithValidMethods([]string{"ES256"})) // asymmetric only; never none or HS*
if err != nil || !parsed.Valid {
return fmt.Errorf("dpop signature: %w", err)
}
claims, ok := parsed.Claims.(jwt.MapClaims)
if !ok {
return errors.New("unexpected claims type")
}
// 1. Method + URL bind the proof to this exact request — replay against
// a different endpoint fails.
if claims["htm"] != r.Method { return errors.New("htm mismatch") }
if claims["htu"] != canonicalURL(r) { return errors.New("htu mismatch") }
// 2. Freshness — accept proofs up to 60s old and at most 30s in the future
// (clock skew). Bound BOTH sides: a far-future iat would otherwise outlive
// the replay cache below and make a pre-generated proof replayable.
// claims come straight off the wire in the client-supplied DPoP header,
// so a missing or wrong-typed iat must fail the request, not panic it.
iatFloat, ok := claims["iat"].(float64)
if !ok { return errors.New("missing or invalid iat claim") }
age := time.Since(time.Unix(int64(iatFloat), 0))
if age > 60*time.Second || age < -30*time.Second {
return errors.New("proof outside the iat window")
}
// 3. Replay defence — jti must stay unique for as long as a proof is
// acceptable: 60s + 30s of skew = the 90s cache TTL. Same reasoning as
// above: a jti of the wrong type must fail cleanly, not panic the handler.
jti, ok := claims["jti"].(string)
if !ok || jti == "" { return errors.New("missing or invalid jti claim") }
if !replayCache.SeenOnce(jti, 90*time.Second) {
return errors.New("dpop replay")
}
// 4. Binding — the proof must name THIS access token (ath = base64url
// SHA-256 of it), and its public key must hash to the token's cnf.jkt.
// Without ath, a proof captured beside one token works with another.
athSum := sha256.Sum256([]byte(accessToken))
if claims["ath"] != base64.RawURLEncoding.EncodeToString(athSum[:]) {
return errors.New("ath mismatch")
}
if jwkThumbprint(parsed.Header["jwk"]) != accessTokenCnf {
return errors.New("token-key mismatch")
}
return nil
}The replay cache is the only stateful piece; a Redis SET NX EX 90 is sufficient and scales linearly with traffic. Adopt DPoP for any flow where the access token traverses a network boundary you don't fully control — third-party API gateways, partner integrations, mobile-to-backend hops over carrier networks. Pure first-party SPA-to-API traffic over a single TLS-terminating ingress derives less benefit, but the cost is two dozen lines of middleware, so the calculus usually favours adoption.
Frequently Asked Questions
What is the difference between OAuth2 and OpenID Connect?
OAuth2 is an authorization framework that grants third-party applications limited access to resources. OpenID Connect (OIDC) is an identity layer built on top of OAuth2 that adds authentication — it tells you who the user is via an ID token (JWT), while OAuth2 only tells you what the user authorized.
Why is PKCE required for public clients like SPAs and mobile apps?
PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks. Public clients cannot securely store a client secret, so PKCE uses a dynamically generated code_verifier and code_challenge pair to prove the client that started the flow is the same one exchanging the code for tokens.
Should I use JWTs or opaque tokens for access tokens?
JWTs allow stateless validation (no database lookup) but cannot be instantly revoked. Opaque tokens require introspection on every request but can be revoked immediately. Use opaque tokens when instant revocation matters (financial, healthcare); use JWTs with short expiry and refresh token rotation for lower-stakes scenarios.
What is refresh token rotation and why does it matter?
Refresh token rotation issues a new refresh token with every access token refresh. If a stolen refresh token is used, the legitimate user's next refresh attempt fails (the old token is invalidated), alerting the system to a potential breach. Without rotation, a stolen refresh token grants indefinite access.
Keep Reading
- Building an OAuth2 Authorization Server from Scratch — The server side of every flow: authorization endpoint, PKCE validation, JWT signing, JWKS, and token rotation in Go
- REST vs gRPC vs GraphQL: A Production Decision Guide — How to secure different API protocols with OAuth2: Bearer tokens for REST, credentials for gRPC
- DNS Records: The Complete Production Guide for Backend Engineers — SPF, DKIM, DMARC records that protect password resets and email verification flows
Sources
- 1.A05:2025 Injection — OWASP Top 10:2025 — OWASP, 2025
- 2.The OAuth 2.0 Authorization Framework — IETF, 2012
- 3.Proof Key for Code Exchange by OAuth Public Clients (PKCE) — IETF, 2015
- 4.JSON Web Token (JWT) — IETF, 2015
- 5.OAuth 2.0 Token Revocation — IETF, 2013
- 6.OAuth 2.0 Demonstrating Proof of Possession (DPoP) — IETF, 2023
Engineering Team
An independent engineering publication covering distributed systems, databases, and production infrastructure. Every factual claim is cited to a primary source or removed.
Read Next
Building an OAuth2 Authorization Server from Scratch
Understand OAuth2 by building the server side: authorization endpoint, PKCE validation, JWT generation, JWKS endpoint, and token rotation — all in Go.
DNS Records: The Complete Production Guide for Backend Engineers
Every DNS record type for production: A, CNAME, MX, TXT, CAA, SRV. TTL failover math, SPF/DKIM/DMARC, GeoDNS, and DNSSEC.
Consistent Hashing: The Algorithm Behind Scalable Distributed Systems
Adding one cache server shouldn't invalidate every key. Consistent hashing with virtual nodes and bounded loads — full Go and Java implementations.