Skip to content

OAuth2 and OpenID Connect: Production Security Patterns

BackendBytes Engineering Team
BackendBytes Engineering Team
9 min read
OAuth2 and OpenID Connect: Production Security Patterns

Key Takeaways

  • localStorage.getItem('access_token') — a single XSS vulnerability lets an attacker silently exfiltrate tokens and bypass passwords + 2FA entirely
  • PKCE prevents authorization code interception on devices with multiple apps by using a dynamically generated code_verifier that only the original client has
  • Refresh token rotation issues a new token with every refresh; if stolen and used, the legitimate user's next refresh fails, alerting the system to a potential breach
  • JWTs allow stateless validation but cannot be instantly revoked; opaque tokens require introspection per request but revoke immediately — choose based on revocation latency requirements

In 2022, a security researcher documented a vulnerability pattern affecting dozens of web applications: session tokens stored in localStorage, readable by any JavaScript on the page. We've seen similar patterns in production: tokens with no expiry, no binding to the original IP or user agent. A single XSS vulnerability — an injected ad script, a compromised npm package, a browser extension with overly broad permissions — could silently exfiltrate them.

The attacker didn't need passwords. They didn't need to bypass two-factor authentication. They needed 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

[RFC 6749, 2012]
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[RFC 6749, 2012], and the app exchanges an authorization code for tokens.

PKCE (Proof Key for Code Exchange)[RFC 7636 — PKCE] 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"
    "net/http"
    "time"
 
    "golang.org/x/oauth2"
)
 
type Handler struct {
    config *oauth2.Config
}
 
// generateCodeVerifier: 32 random bytes, base64-URL-encoded (43 chars).
func generateCodeVerifier() string {
    b := make([]byte, 32)
    rand.Read(b)
    return base64.RawURLEncoding.EncodeToString(b)
}
 
// 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 := generateCodeVerifier()
    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)

[RFC 6749, 2012]

The theft-detection state machine in one picture — replay of any rotated token revokes the entire family:

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, exactly one refresh token in the family is active. Presenting any other (rotated or revoked) token triggers family-wide revocation on the assumption that the original token was stolen.

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"
    "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, store record, return token.
func IssueRefreshToken(userID string, db *DB) (token string, record *RefreshTokenRecord, err error) {
    token = generateSecureRandom(32)
    tokenHash := hashToken(token)
    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.
func UseRefreshToken(userID, clientToken string, db *DB) (newAccessToken, newRefreshToken string, err error) {
    tokenHash := hashToken(clientToken)
 
    // Look up token by hash.
    record, err := db.GetRefreshTokenByHash(tokenHash)
    if err != nil || record == nil {
        return "", "", ErrInvalidToken
    }
 
    // Token already used? (Reuse = theft detection)
    if !record.LastUsedAt.IsZero() {
        // Revoke entire family — attacker may be in possession.
        db.RevokeTokenFamily(record.FamilyID)
        return "", "", ErrTokenReused // Force user to re-login
    }
 
    // Expired?
    if time.Now().After(record.ExpiresAt) {
        return "", "", ErrTokenExpired
    }
 
    // Mark as used.
    record.LastUsedAt = time.Now()
    db.UpdateRefreshToken(record)
 
    // Issue new tokens (refresh token rotates).
    newRefreshToken, newRecord, err := IssueRefreshToken(userID, db)
    if err != nil {
        return "", "", err
    }
    newRecord.FamilyID = record.FamilyID // Link to the same family
 
    // Issue short-lived access token.
    newAccessToken, err = issueAccessToken(userID, time.Hour)
    if err != nil {
        return "", "", err
    }
 
    return newAccessToken, newRefreshToken, nil
}
 
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

[RFC 7519, 2015]

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.

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[RFC 7009 — Token Revocation]. On logout, password change, or account compromise, revoke tokens so they stop working immediately.

func (h *Handler) Revoke(w http.ResponseWriter, r *http.Request) {
    token := r.FormValue("token")
    tokenTypeHint := r.FormValue("token_type_hint") // "access_token" or "refresh_token"
 
    // Look up and mark revoked in DB.
    tokenHash := hashToken(token)
    if err := h.db.RevokeToken(tokenHash); err != nil {
        w.WriteHeader(http.StatusBadRequest)
        return
    }
 
    // For refresh tokens: revoke the entire family.
    if tokenTypeHint == "refresh_token" {
        record, _ := h.db.GetRefreshTokenByHash(tokenHash)
        if record != nil {
            h.db.RevokeTokenFamily(record.FamilyID)
        }
    }
 
    // 200 OK, even if token was already revoked (RFC 7009).
    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 the failure mode every team hits: when the IdP rotates signing keys mid-cache, every token signed with the new key fails kid not found. The verifier below does an opportunistic JWKS refresh on unknown kid, with a circuit-breaker so a flapping IdP doesn't DOS your auth path:

package authn
 
import (
	"context"
	"errors"
	"fmt"
	"sync"
	"sync/atomic"
	"time"
 
	"github.com/MicahParks/keyfunc/v3"
	"github.com/golang-jwt/jwt/v5"
)
 
type JWKSVerifier struct {
	jwks       *keyfunc.JWKS
	jwksURL    string
	expectedIss string
	expectedAud string
 
	// Refresh-on-miss safety net: bound IdP load when many requests
	// arrive with a kid the cache hasn't seen yet (key rotation event).
	mu          sync.Mutex
	lastRefresh atomic.Int64 // unix-millis of last on-demand refresh
}
 
const minRefreshIntervalMs = 30_000 // never re-fetch more than 2× per minute
 
func NewJWKSVerifier(ctx context.Context, jwksURL, iss, aud string) (*JWKSVerifier, error) {
	jwks, err := keyfunc.NewDefaultCtx(ctx, []string{jwksURL})
	if err != nil {
		return nil, fmt.Errorf("jwks bootstrap: %w", err)
	}
	return &JWKSVerifier{jwks: jwks, jwksURL: jwksURL, expectedIss: iss, expectedAud: aud}, nil
}
 
func (v *JWKSVerifier) Verify(ctx context.Context, raw string) (*jwt.RegisteredClaims, error) {
	parse := func() (*jwt.Token, error) {
		// In golang-jwt v5 the per-claim VerifyXXX methods were removed; audience
		// and issuer are enforced by the built-in validator via parser options.
		return jwt.ParseWithClaims(raw, &jwt.RegisteredClaims{}, v.jwks.Keyfunc,
			jwt.WithAudience(v.expectedAud),
			jwt.WithIssuer(v.expectedIss),
		)
	}
 
	token, err := parse()
	// Opportunistic refresh on unknown-kid, rate-limited.
	if err != nil && errors.Is(err, jwt.ErrTokenSignatureInvalid) {
		now := time.Now().UnixMilli()
		if now-v.lastRefresh.Load() > minRefreshIntervalMs {
			v.mu.Lock()
			if time.Now().UnixMilli()-v.lastRefresh.Load() > minRefreshIntervalMs {
				_ = v.jwks.Refresh(ctx)
				v.lastRefresh.Store(time.Now().UnixMilli())
			}
			v.mu.Unlock()
			token, err = parse()
		}
	}
	if err != nil {
		return nil, fmt.Errorf("jwt parse: %w", err)
	}
 
	// Audience and issuer were already validated by the parser options above;
	// a returned token with token.Valid == true has passed both checks.
	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 — second unknown-kid attempt within 30s
	// must NOT hit the IdP again. Fail open with a clear error.
	srv.AssertNoMoreFetchesFor(t, 30*time.Second)
}

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)[RFC 9449, 2023] 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 thumbprint matches the access 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.
      eyJodG0iOiJQT1NUIiwiaHR1IjoiaHR0cHM6Ly9hcGkuZXhhbXBsZS5jb20vcGF5bWVudHMiLAogICAgImlhdCI6MTcyNTQ2MTAwMCwiCiAgICAianRpIjoiOWY4Yi0xMjM0In0.
      MEUCIQDX...
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, accessTokenCnf string) error {
    proof := r.Header.Get("DPoP")
    parsed, err := jwt.Parse(proof, func(t *jwt.Token) (interface{}, error) {
        // 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)
    })
    if err != nil || !parsed.Valid {
        return fmt.Errorf("dpop signature: %w", err)
    }
 
    claims := parsed.Claims.(jwt.MapClaims)
 
    // 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 — proofs older than 60s are rejected (clock skew tolerance).
    iat := time.Unix(int64(claims["iat"].(float64)), 0)
    if time.Since(iat) > 60*time.Second { return errors.New("proof expired") }
 
    // 3. Replay defence — jti must be unique within the freshness window.
    if !replayCache.SeenOnce(claims["jti"].(string), 90*time.Second) {
        return errors.New("dpop replay")
    }
 
    // 4. Binding — proof's public key must hash to the access token's cnf.jkt.
    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

BackendBytes Engineering Team
BackendBytes Engineering Team

Engineering Team

A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.

Read Next