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.
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]| 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[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
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 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
- 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
Engineering Team
A multidisciplinary team of backend engineers, architects, and DevOps practitioners shipping deep dives into distributed systems and production infrastructure.
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 Every Scalable Distributed System
Adding one cache server shouldn't invalidate every key. Consistent hashing with virtual nodes and bounded loads — full Go and Java implementations.