Skip to content

Building an OAuth2 Authorization Server from Scratch

Key Takeaways

  • →Building an OAuth2 server from scratch teaches you why the spec requires PKCE and state parameters — you'll debug token failures instantly because you've seen the validation code
  • →PKCE prevents authorization code interception by requiring the client to prove it initiated the request — SHA256(code_verifier) must match the stored code_challenge at token exchange
  • →The JWKS endpoint at /.well-known/jwks.json exposes public keys for distributed signature verification without sharing secrets; key rotation happens transparently via key IDs
  • →Refresh token rotation on every use enables theft detection — a stolen token becomes invalid immediately, signaling compromise and allowing revocation of the entire token family

It's 3 AM and your SPA has stopped logging users in. The token endpoint returns invalid_grant and the logs don't explain why. The client is sending a code_verifier, but you have no visibility into whether the PKCE validation passed server-side, which endpoint actually received it, or how your code_challenge comparison logic works. Hours later it turns out a misconfiguration sent requests to the wrong endpoint, the verifier landed somewhere it shouldn't, and the SHA-256 mismatch killed the entire flow. The fastest debugging path was rebuilding the server-side mental model from the RFC.

That's the point of this article: walk through OAuth 2.0⁠[1] and JWT⁠[2] from the server's perspective so you can debug client failures instantly.

Don't Do This In Production

This article builds a minimal OAuth2 authorization server for educational purposes. In production, use Keycloak, Auth0, Zitadel, or Dex. The value here is understanding how servers validate requests, issue tokens, and maintain security invariants so you debug failures intelligently.

Why Build One?

Most OAuth2 guides teach the client side. This article covers the server side: how the authorization server validates requests, issues tokens, and maintains security invariants.

TL;DR

Build a minimal OAuth2 server⁠[1] to understand why the spec requires PKCE, state parameters, and token rotation. You'll debug integration failures instantly and design better security boundaries.

  • Implement the authorization endpoint with PKCE code_challenge validation
  • Exchange authorization codes for signed JWTs⁠[2] via the token endpoint
  • Expose public keys at /.well-known/jwks.json for distributed signature verification
sequenceDiagram
    participant U as User
    participant C as Client (SPA)
    participant AS as Auth server
    participant RS as Resource server
    C->>C: gen code_verifier, code_challenge = SHA256 of verifier
    U->>C: clicks "Sign in"
    C->>AS: GET /authorize?client_id=&code_challenge=&state=
    AS->>U: login + consent
    U-->>AS: approve
    AS-->>C: 302 redirect_uri?code=&state=
    C->>AS: POST /token with code + code_verifier
    AS->>AS: SHA256 of verifier == stored challenge?
    alt match
        AS-->>C: access_token JWT + refresh_token
    else mismatch
        AS-->>C: 400 invalid_grant 🔥
    end
    C->>RS: GET /api with Authorization: Bearer JWT
    RS->>AS: GET /.well-known/jwks.json, cached
    RS->>RS: verify JWT signature with public key
    RS-->>C: 200 OK / 401

The diagram shows where every common OAuth2 failure surfaces: PKCE mismatch at the token endpoint, JWT signature verification at the resource server, and state validation on the redirect. If you can name the box that fired the error, you can fix it.

Understanding the server's perspective shows you why the documentation says what it says. You'll debug token validation failures instantly, understand why PKCE protects against interception, and integrate confidently with any identity provider.

The Quick Start

StepEndpointWhat it validates
1. Authorization requestGET /oauth/authorize?client_id=...&code_challenge=...Validates client_id, redirect_uri, PKCE code_challenge
2. User consents(Login + consent UI)Authenticates user, confirms authorization scope
3. Redirect to app302 redirect_uri?code=abc123&state=xyzCode tied to client_id and code_challenge
4. Token exchangePOST /oauth/token (code + code_verifier)Verifies SHA256(code_verifier) == code_challenge, issues JWT + refresh
5. Verify signatureGET /.well-known/jwks.jsonClient fetches public key, validates JWT locally

The client initiates → auth server generates a code → code is exchanged for a JWT → JWT is verified using the public key.

Key Generation and JWT Signing

The authorization server signs JWTs with an RSA private key. Clients verify signatures with the public key from the JWKS endpoint.

package authserver
 
import (
	"crypto/rand"
	"crypto/rsa"
	"crypto/x509"
	"encoding/pem"
	"fmt"
 
	"github.com/golang-jwt/jwt/v5"
)
 
// KeyManager handles RSA key generation and storage.
type KeyManager struct {
	privateKey *rsa.PrivateKey
	publicKey  *rsa.PublicKey
	keyID      string
}
 
// NewKeyManager generates a fresh 2048-bit RSA key pair.
func NewKeyManager() (*KeyManager, error) {
	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
	if err != nil {
		return nil, fmt.Errorf("key generation: %w", err)
	}
 
	return &KeyManager{
		privateKey: privateKey,
		publicKey:  &privateKey.PublicKey,
		keyID:      "default-key",
	}, nil
}
 
// ExportPublicKey returns the public key in PEM format for JWKS.
func (km *KeyManager) ExportPublicKey() (string, error) {
	pubKeyBytes, err := x509.MarshalPKIXPublicKey(km.publicKey)
	if err != nil {
		return "", fmt.Errorf("marshal public key: %w", err)
	}
 
	pubKeyPEM := pem.EncodeToMemory(&pem.Block{
		Type:  "PUBLIC KEY",
		Bytes: pubKeyBytes,
	})
 
	return string(pubKeyPEM), nil
}
 
// IssueJWT signs claims with the server's RSA private key using RS256 and
// returns the compact JWT string. The key ID is set on the header so
// verifiers can pick the right JWK out of the JWKS response.
func (km *KeyManager) IssueJWT(claims jwt.MapClaims) (string, error) {
	token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
	token.Header["kid"] = km.keyID
 
	signed, err := token.SignedString(km.privateKey)
	if err != nil {
		return "", fmt.Errorf("sign jwt: %w", err)
	}
 
	return signed, nil
}

The Authorization Endpoint with PKCE

The full Authorization Code + PKCE flow in one picture — every arrow protects against a specific attack:

sequenceDiagram
    participant App as Mobile/SPA app
    participant AS as Auth server
    participant U as User browser
    participant API as Resource server
    Note over App: Step 1 — generate PKCE pair
    App->>App: code_verifier = random 43-128 chars<br/>code_challenge = SHA256 of verifier
    Note over App,U: Step 2 — authorize
    App->>U: redirect /authorize<br/>+ code_challenge + state
    U->>AS: GET /authorize
    AS->>U: login + consent UI
    U->>AS: submit credentials
    AS->>AS: validate, bind code to<br/>client_id + code_challenge
    AS->>U: 302 redirect with code + state
    U->>App: deliver code via custom URL scheme
    Note over App,AS: Step 3 — exchange code for tokens
    App->>AS: POST /token<br/>code + code_verifier
    AS->>AS: SHA256 of verifier == challenge?<br/>code unused? not expired?
    AS->>App: access_token + refresh_token
    Note over App,API: Step 4 — call API
    App->>API: GET /resource<br/>Bearer access_token
    API->>API: validate JWT signature via JWKS
    API->>App: protected data

The code_challenge / code_verifier handshake⁠[3] blocks the authorization-code interception attack: a malicious app on the same device can intercept the redirect, but it does not have the verifier — without it, the /token exchange fails.

The authorization endpoint validates the PKCE code_challenge and generates an authorization code tied to the client and code_challenge.

// AuthorizationRequest holds incoming authorization request parameters.
type AuthorizationRequest struct {
	ClientID            string
	RedirectURI         string
	CodeChallenge       string
	CodeChallengeMethod string // only "S256" is accepted below
	State               string // CSRF protection
}
 
// AuthorizationCode stores state needed for token exchange.
type AuthorizationCode struct {
	Code                string
	ClientID            string
	RedirectURI         string
	CodeChallenge       string
	CodeChallengeMethod string
	ExpiresAt           time.Time
	Used                bool
}
 
// AuthorizationEndpoint validates the request and generates a code.
func (as *AuthServer) AuthorizationEndpoint(w http.ResponseWriter, r *http.Request) {
	q := r.URL.Query()
	clientID := q.Get("client_id")
	redirectURI := q.Get("redirect_uri")
	// Exact match against the URIs registered for this client. On failure,
	// show an error page; never redirect to an unverified URI.
	if !as.clients.RedirectURIAllowed(r.Context(), clientID, redirectURI) {
		http.Error(w, "unknown client_id or unregistered redirect_uri", http.StatusBadRequest)
		return
	}
 
	// PKCE: code_challenge is required and only S256 is accepted. A missing
	// method means "plain", which the token endpoint below does not implement.
	codeChallenge := q.Get("code_challenge")
	if codeChallenge == "" || q.Get("code_challenge_method") != "S256" {
		http.Error(w, "PKCE code_challenge with method S256 required", http.StatusBadRequest)
		return
	}
 
	// Store the authorization code with PKCE challenge
	code, err := generateRandomCode(32)
	if err != nil {
		http.Error(w, "server error", http.StatusInternalServerError)
		return
	}
	authCode := AuthorizationCode{
		Code:                code,
		ClientID:            clientID,
		RedirectURI:         redirectURI,
		CodeChallenge:       codeChallenge,
		CodeChallengeMethod: "S256",
		ExpiresAt:           time.Now().Add(10 * time.Minute),
		Used:                false,
	}
	as.storeAuthCode(code, authCode)
 
	// Redirect back with code and state, keeping any query component the
	// registered URI already has instead of string-concatenating "?code=".
	u, err := url.Parse(redirectURI)
	if err != nil {
		http.Error(w, "server error", http.StatusInternalServerError)
		return
	}
	params := u.Query()
	params.Set("code", code)
	if state := q.Get("state"); state != "" {
		params.Set("state", state)
	}
	u.RawQuery = params.Encode()
	http.Redirect(w, r, u.String(), http.StatusFound)
}
 
func generateRandomCode(length int) (string, error) {
	b := make([]byte, length)
	// crypto/rand can fail; never ignore it — a short read would yield a
	// weak or all-zero token. Propagate the error so the caller aborts.
	if _, err := rand.Read(b); err != nil {
		return "", fmt.Errorf("generate random code: %w", err)
	}
	return base64.RawURLEncoding.EncodeToString(b), nil
}

The Token Endpoint: PKCE Verification and JWT Issuance

The token endpoint verifies the code and code_verifier match, then issues a signed JWT access token and a refresh token.

// TokenRequest holds the code exchange request.
type TokenRequest struct {
	Code         string
	CodeVerifier string
	ClientID     string
}
 
// TokenResponse holds the issued tokens.
type TokenResponse struct {
	AccessToken  string `json:"access_token"`
	RefreshToken string `json:"refresh_token"`
	ExpiresIn    int    `json:"expires_in"`
	TokenType    string `json:"token_type"`
}
 
// TokenEndpoint exchanges an authorization code for tokens.
func (as *AuthServer) TokenEndpoint(w http.ResponseWriter, r *http.Request) {
	if r.Method != http.MethodPost {
		http.Error(w, "only POST allowed", http.StatusMethodNotAllowed)
		return
	}
 
	code := r.FormValue("code")
	codeVerifier := r.FormValue("code_verifier")
 
	// Retrieve the authorization code
	authCode, exists := as.getAuthCode(code)
	if !exists || authCode.Used || authCode.ExpiresAt.Before(time.Now()) {
		http.Error(w, "invalid or expired code", http.StatusBadRequest)
		return
	}
	// The code is bound to the client and redirect_uri it was issued for.
	if r.FormValue("client_id") != authCode.ClientID ||
		r.FormValue("redirect_uri") != authCode.RedirectURI {
		http.Error(w, "invalid_grant", http.StatusBadRequest)
		return
	}
 
	// PKCE verification: verify SHA256(code_verifier) == code_challenge
	hash := sha256.Sum256([]byte(codeVerifier))
	challenge := base64.RawURLEncoding.EncodeToString(hash[:])
 
	if challenge != authCode.CodeChallenge {
		http.Error(w, "PKCE verification failed", http.StatusBadRequest)
		return
	}
 
	// Mark code as used (prevent replay attacks)
	authCode.Used = true
	as.storeAuthCode(code, authCode)
 
	// Issue JWT access token
	claims := jwt.MapClaims{
		"sub":   authCode.ClientID,
		"aud":   as.audience, // the API that will accept this token, not the client
		"iat":   time.Now().Unix(),
		"exp":   time.Now().Add(15 * time.Minute).Unix(),
		"scope": "read write",
	}
	accessToken, err := as.keyMgr.IssueJWT(claims)
	if err != nil {
		http.Error(w, "token issuance failed", http.StatusInternalServerError)
		return
	}
 
	// Issue refresh token
	refreshToken, err := generateRandomCode(64)
	if err != nil {
		http.Error(w, "token issuance failed", http.StatusInternalServerError)
		return
	}
	as.storeRefreshToken(refreshToken, authCode.ClientID)
 
	response := TokenResponse{
		AccessToken:  accessToken,
		RefreshToken: refreshToken,
		ExpiresIn:    900,
		TokenType:    "Bearer",
	}
 
	w.Header().Set("Content-Type", "application/json")
	// RFC 6749 §5.1: responses carrying tokens must never be cached.
	w.Header().Set("Cache-Control", "no-store")
	w.Header().Set("Pragma", "no-cache")
	if err := json.NewEncoder(w).Encode(response); err != nil {
		// Headers and status are already written; the only thing left
		// to do is record that the client got a truncated body.
		slog.Error("token endpoint: encode response", "error", err)
	}
}

The JWKS Endpoint for Public Key Distribution

The JWKS endpoint exposes the public key so resource servers verify JWT signatures without sharing secrets.

// JWKS represents the JSON Web Key Set response.
type JWKS struct {
	Keys []JWK `json:"keys"`
}
 
// JWK represents a single JSON Web Key.
type JWK struct {
	Kty string `json:"kty"` // "RSA"
	Kid string `json:"kid"` // Key ID (matches JWT header "kid")
	Use string `json:"use"` // "sig" for signing
	N   string `json:"n"`   // RSA modulus (base64url)
	E   string `json:"e"`   // RSA exponent (base64url)
}
 
// JWKSEndpoint serves the public keys at /.well-known/jwks.json.
func (as *AuthServer) JWKSEndpoint(w http.ResponseWriter, r *http.Request) {
	pubKey := as.keyMgr.publicKey
	n := base64.RawURLEncoding.EncodeToString(pubKey.N.Bytes())
	e := base64.RawURLEncoding.EncodeToString(big.NewInt(int64(pubKey.E)).Bytes())
 
	jwk := JWK{
		Kty: "RSA",
		Kid: as.keyMgr.keyID,
		Use: "sig",
		N:   n,
		E:   e,
	}
 
	response := JWKS{Keys: []JWK{jwk}}
 
	w.Header().Set("Content-Type", "application/json")
	if err := json.NewEncoder(w).Encode(response); err != nil {
		slog.Error("jwks endpoint: encode response", "error", err)
	}
}

Refresh Token Rotation with Family Revocation

Long-lived refresh tokens are the highest-value target in any OAuth2 system. The defense is rotation with family revocation: every refresh issues a new refresh token AND invalidates the old one; if a revoked token is ever presented, you revoke the entire family on the assumption that the old token was stolen⁠[4].

func serverError(w http.ResponseWriter, msg string, err error) {
    slog.Error(msg, "error", err)
    http.Error(w, "server_error", http.StatusInternalServerError)
}
 
// /token endpoint, grant_type=refresh_token branch
func handleRefresh(w http.ResponseWriter, r *http.Request, presented string) {
    rt, err := store.LookupRefreshToken(r.Context(), presented)
    if errors.Is(err, ErrNotFound) {
        http.Error(w, "invalid_grant", http.StatusBadRequest)
        return
    }
    if err != nil {
        // A database outage is not invalid_grant: that answer makes
        // clients discard their tokens and log every user out.
        serverError(w, "refresh: lookup", err)
        return
    }
 
    // CRITICAL: presented token has already been used (rotated)
    if rt.RotatedAt != nil {
        // Theft detection: a revoked token was replayed.
        // Revoke every token in this rotation family (one login);
        // the user's other sessions stay valid.
        if err := store.RevokeFamily(r.Context(), rt.FamilyID); err != nil {
            // The stolen chain may still be live; fail loudly.
            serverError(w, "refresh-token replay: family revocation failed", err)
            return
        }
        slog.Warn("refresh-token replay detected; family revoked",
            "family", rt.FamilyID, "user", rt.UserID, "client", rt.ClientID)
        http.Error(w, "invalid_grant", http.StatusBadRequest)
        return
    }
 
    // Issue a new access token + new refresh token
    newAT, err := signAccessToken(rt.UserID, rt.ClientID, rt.Scopes, 15*time.Minute)
    if err != nil {
        serverError(w, "refresh: sign access token", err)
        return
    }
    newRT, err := generateRandomCode(64)
    if err != nil {
        serverError(w, "refresh: generate refresh token", err)
        return
    }
 
    if err := store.RotateRefreshToken(r.Context(), rt.ID, newRT, 30*24*time.Hour); err != nil {
        serverError(w, "refresh: rotate", err)
        return
    }
 
    w.Header().Set("Content-Type", "application/json")
    w.Header().Set("Cache-Control", "no-store")
    w.Header().Set("Pragma", "no-cache")
    if err := json.NewEncoder(w).Encode(map[string]any{
        "access_token":  newAT,
        "refresh_token": newRT,
        "token_type":    "Bearer",
        "expires_in":    900,
    }); err != nil {
        slog.Error("refresh endpoint: encode response", "error", err)
    }
}

The rotation invariant: at any moment, exactly one refresh token in the family is valid. Replay of any older token is treated as theft.

Production Checklist

  • PKCE validation is mandatory for all public clients (mobile apps, SPAs, CLIs)
  • Authorization codes expire within 10 minutes and are single-use
  • Access tokens include audience (aud), subject (sub), and expiration (exp) claims
  • Refresh tokens are rotated on every use; revoke the entire token family if a revoked token is presented
  • JWKS responses are cacheable (e.g. Cache-Control: max-age=86400); token responses send Cache-Control: no-store
  • Tokens are issued with short TTL (1–15 minutes for access, 7–30 days for refresh)
  • Incoming requests are rate-limited per client_id to prevent brute-force attacks
  • All endpoints enforce HTTPS (TLS 1.2+) and set security headers (HSTS, X-Frame-Options)
  • Use a mature library (golang-jwt, jose4j, jsonwebtoken) instead of hand-rolling cryptography

Operational glue: rotation, revocation, and the audit log

The endpoints a from-scratch OAuth2 server gets right on the happy path but skips on the operational ones — key rotation, refresh-token-family revocation, and the audit log auditors will ask for. Together they're what keeps a hand-rolled IdP from becoming an incident generator.

Key rotation: ship the new key alongside the old one for a TTL window equal to your longest token lifetime, then retire the old one. The JWKS endpoint exposes both during the overlap so existing tokens keep verifying:

type SigningKeyStore struct {
    mu      sync.RWMutex
    current *KeyPair  // active for new tokens
    retired *KeyPair  // accepted on verify, not used for signing
}
 
// Rotate moves current → retired and brings in a new signing key.
// Retired key is dropped after the next rotation (TTL-based).
func (s *SigningKeyStore) Rotate(next *KeyPair) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.retired = s.current
    s.current = next
}
 
func (s *SigningKeyStore) JWKS() jose.JSONWebKeySet {
    s.mu.RLock()
    defer s.mu.RUnlock()
    keys := []jose.JSONWebKey{s.current.public()}
    if s.retired != nil {
        keys = append(keys, s.retired.public())
    }
    return jose.JSONWebKeySet{Keys: keys}
}

Refresh-token rotation with family revocation — the canonical defence against a stolen refresh token. If a previously-rotated token is presented (i.e., the attacker uses an old token while the legitimate user already rotated), revoke the entire chain:

-- refresh_tokens table — every refresh keeps the family ID across rotations
CREATE TABLE refresh_tokens (
    id          TEXT PRIMARY KEY,
    family_id   TEXT NOT NULL,
    user_id     BIGINT NOT NULL,
    parent_id   TEXT,                 -- chain back to predecessor
    issued_at   TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at  TIMESTAMPTZ NOT NULL,
    revoked_at  TIMESTAMPTZ
);
 
CREATE INDEX rt_family_idx ON refresh_tokens (family_id);

The handler that detects replay and nukes the family — the breach detection RFC 9700 §4.14 describes for refresh token rotation⁠[4]:

func (s *TokenServer) Refresh(ctx context.Context, presented string) (*TokenPair, error) {
    rt, err := s.repo.GetByID(ctx, presented)
    if errors.Is(err, ErrNotFound) {
        return nil, ErrInvalidGrant
    }
    if err != nil {
        return nil, fmt.Errorf("load refresh token: %w", err)
    }
 
    if rt.RevokedAt.Valid {
        // Replay of a revoked token. Nuke the entire family — the legitimate
        // owner will be forced to re-authenticate, which is the correct
        // outcome when a token is suspected stolen.
        s.audit.Log(ctx, AuditEvent{
            Type:     "refresh_token_replay",
            FamilyID: rt.FamilyID,
            UserID:   rt.UserID,
        })
        if err := s.repo.RevokeFamily(ctx, rt.FamilyID); err != nil {
            // Not ErrInvalidGrant: the stolen chain may still be live.
            return nil, fmt.Errorf("revoke family %s after replay: %w", rt.FamilyID, err)
        }
        return nil, ErrInvalidGrant
    }
    // Mark this rotation step revoked, mint successor in same family.
    return s.rotate(ctx, rt)
}

The audit-log schema — append-only with content-hash chaining so any tampering is detectable on the hash chain:

CREATE TABLE auth_audit (
    id              BIGSERIAL PRIMARY KEY,
    occurred_at     TIMESTAMPTZ NOT NULL DEFAULT now(),
    event_type      TEXT NOT NULL,
    actor_user_id   BIGINT,
    client_id       TEXT,
    ip              INET,
    user_agent      TEXT,
    context         JSONB NOT NULL,
    -- Each row's hash includes the previous row's hash; tamper-detection chain.
    row_hash        BYTEA NOT NULL,
    prev_row_hash   BYTEA NOT NULL
);
 
-- Single index covers the two queries auditors actually run.
CREATE INDEX auth_audit_actor_time ON auth_audit (actor_user_id, occurred_at DESC);

A common mistake on a hand-rolled IdP: skipping the audit log because "we'll add it later when we have a use case." The use case arrives as a customer reporting unauthorized access, and you need to reconstruct the auth events leading up to it. Without the chain, you cannot prove whether your IdP issued the token or whether the row was modified after the fact.

Token Revocation Endpoint (RFC 7009)

Rotation handles the silent case — a refresh token replayed by an attacker. Explicit revocation handles the loud case — a user signs out, an admin disables an account, or a security tool spots a leaked credential and needs to kill the token immediately. Both paths exist for a reason and a production server has to implement both.

The revocation endpoint defined by RFC 7009⁠[5] accepts a token plus an optional token_type_hint and returns 200 OK both when it revoked the token and when the token was invalid. The spec's reasoning: the client can't do anything useful with "not found", and the token is unusable either way. A 404 for unknown tokens adds nothing and turns the endpoint into a token-validity oracle.

The wire format is a standard form post:

POST /oauth/revoke HTTP/1.1
Host: auth.example.com
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded
 
token=2YotnFZFEjr1zCsicMWpAA&token_type_hint=refresh_token

The handler resolves the token, revokes the entire family if it is a refresh token (because revoking a single rotation step is meaningless — the family is the security unit), and writes an audit row. Unknown tokens get 200. The spec does define errors: a token issued to a different client is refused, and a 503 tells the client the token still exists and to retry later — the right answer when the revocation write fails:

// RevokeEndpoint implements RFC 7009.
func (s *AuthServer) RevokeEndpoint(w http.ResponseWriter, r *http.Request) {
    if r.Method != http.MethodPost {
        http.Error(w, "only POST allowed", http.StatusMethodNotAllowed)
        return
    }
 
    clientID, clientSecret, ok := r.BasicAuth()
    if !ok || !s.clients.Authenticate(r.Context(), clientID, clientSecret) {
        w.Header().Set("WWW-Authenticate", `Basic realm="oauth"`)
        http.Error(w, "invalid_client", http.StatusUnauthorized)
        return
    }
 
    presented := r.FormValue("token")
    hint := r.FormValue("token_type_hint")
 
    // Resolve in hint order, fall back to the other type. Spec requires
    // we accept either type regardless of hint.
    rt, found := s.lookupForRevoke(r.Context(), presented, hint)
    if !found {
        w.WriteHeader(http.StatusOK) // invalid token: nothing left to revoke
        return
    }
    if rt.ClientID != clientID {
        // RFC 7009 §2.1: refuse a token that was issued to another client.
        http.Error(w, "unauthorized_client", http.StatusBadRequest)
        return
    }
 
    var err error
    switch rt.Kind {
    case KindRefresh:
        err = s.repo.RevokeFamily(r.Context(), rt.FamilyID)
    case KindAccess:
        err = s.repo.RevokeAccessToken(r.Context(), rt.ID)
    }
    if err != nil {
        // A 200 here would tell the client the token is dead when it isn't.
        slog.Error("revoke: storage write failed", "client", clientID, "error", err)
        w.Header().Set("Retry-After", "5")
        http.Error(w, "revocation temporarily unavailable", http.StatusServiceUnavailable)
        return
    }
    s.audit.Log(r.Context(), AuditEvent{
        Type: "token_revoked", FamilyID: rt.FamilyID,
        ClientID: clientID, UserID: rt.UserID,
    })
    w.WriteHeader(http.StatusOK)
}

The two security invariants worth emphasising: the endpoint enforces client authentication (a stolen token is not enough — the attacker needs the client credentials to revoke it), and it scopes revocation to tokens issued for the calling client. A multi-tenant server that lets one client revoke another's tokens is a denial-of-service vector.

Resource servers also need a story for revoked-but-still-unexpired access tokens. JWTs are stateless — once issued, they verify until exp regardless of revocation state. Two pragmatic options: keep access-token TTLs short enough that revocation latency is acceptable (1–15 minutes is typical), or maintain a revocation list that resource servers check before accepting a token. The schema below holds the list; the index on expires_at lets a background job evict rows once the underlying token would have expired anyway:

CREATE TABLE revoked_tokens (
    jti           TEXT PRIMARY KEY,         -- JWT ID claim
    family_id     TEXT,                     -- null for access tokens
    revoked_at    TIMESTAMPTZ NOT NULL DEFAULT now(),
    expires_at    TIMESTAMPTZ NOT NULL,     -- original token exp
    reason        TEXT NOT NULL             -- 'user_logout' | 'admin_disable' | 'leak_detected'
);
 
CREATE INDEX revoked_tokens_expiry ON revoked_tokens (expires_at);
 
-- Background job: evict rows whose underlying token has already expired.
DELETE FROM revoked_tokens WHERE expires_at < now() - INTERVAL '1 hour';

Resource servers query revoked_tokens by jti on every request — a single indexed lookup. A revoked result can be cached for the token's remaining TTL; a clean result cannot, or revocation stops taking effect. The 1-hour grace window in the eviction query is paranoia: it covers clock skew between the auth server and resource servers so a token isn't accepted because the revocation row was deleted half a second too early.

Frequently Asked Questions

What is PKCE and why is it required for OAuth2?

PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks by requiring the client to prove it initiated the original authorization request. The client sends a hashed code_challenge during authorization and the original code_verifier during token exchange — the server verifies SHA256(code_verifier) matches the stored challenge.

How does a JWKS endpoint work in OAuth2?

The JWKS (JSON Web Key Set) endpoint at /.well-known/jwks.json exposes the authorization server's public keys so resource servers can verify JWT signatures without sharing secrets. Each key includes a key ID (kid) that matches the kid header in issued JWTs, enabling key rotation without invalidating existing tokens.

Why should you rotate refresh tokens on every use?

Rotating refresh tokens on every use enables theft detection. If a stolen refresh token is used, the legitimate client's next request will present an already-revoked token, signaling a compromise. The server then revokes the entire token family, limiting the attacker's window.

Should I build my own OAuth2 authorization server?

No — use a mature identity provider like Keycloak, Auth0, or Zitadel for production. Building one is valuable for understanding how authorization servers validate requests, issue tokens, and maintain security invariants, which helps you integrate and debug real providers.

Keep Reading

Sources

  1. 1.The OAuth 2.0 Authorization Framework — IETF, 2012
  2. 2.JSON Web Token (JWT) — IETF, 2015
  3. 3.Proof Key for Code Exchange by OAuth Public Clients (PKCE) — IETF, 2015
  4. 4.Best Current Practice for OAuth 2.0 Security — IETF, 2025
  5. 5.OAuth 2.0 Token Revocation — IETF, 2013
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