Skip to content

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.

Key Points

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]
FlowWhen to useClient typeHas refresh token?
Authorization Code + PKCEWeb apps, SPAs, mobile apps. User login with redirect.Public (can't store secret)Yes
Client CredentialsBackend service-to-service. No user involved.Confidential (has secret)No — token lasts entire session
Device CodeSmart TV, IoT devices. User approves on separate device.PublicYes
Refresh Token RotationKeep access tokens short-lived, rotate on every refresh. All flows.BothYes, 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 state parameter 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=consent when 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_id for 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 /revoke endpoint
  • 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 aud claim matches your client_id
  • Verify iss claim matches expected issuer
  • Use nonce parameter 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

Sources

  1. 1.A05:2025 Injection — OWASP Top 10:2025 — OWASP, 2025
  2. 2.The OAuth 2.0 Authorization Framework — IETF, 2012
  3. 3.Proof Key for Code Exchange by OAuth Public Clients (PKCE) — IETF, 2015
  4. 4.JSON Web Token (JWT) — IETF, 2015
  5. 5.OAuth 2.0 Token Revocation — IETF, 2013
  6. 6.OAuth 2.0 Demonstrating Proof of Possession (DPoP) — IETF, 2023
BackendBytes Engineering Team
BackendBytes

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