Skip to content

Building an OAuth2 Authorization Server from Scratch

BackendBytes Engineering Team
BackendBytes Engineering Team
6 min read
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

The classic OAuth2-debugging-at-3 AM situation: your SPA stops 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[RFC 6749, 2012] and JWT[RFC 7519, 2015] 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[RFC 6749, 2012] 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[RFC 7519, 2015] 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"
)
 
// 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
}

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[RFC 7636 — PKCE] 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 // "S256" (SHA256) or "plain"
	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) {
	clientID := r.URL.Query().Get("client_id")
	if clientID == "" {
		http.Error(w, "missing client_id", http.StatusBadRequest)
		return
	}
 
	redirectURI := r.URL.Query().Get("redirect_uri")
	if redirectURI == "" {
		http.Error(w, "missing redirect_uri", http.StatusBadRequest)
		return
	}
 
	// PKCE: code_challenge is required
	codeChallenge := r.URL.Query().Get("code_challenge")
	if codeChallenge == "" {
		http.Error(w, "PKCE code_challenge 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: r.URL.Query().Get("code_challenge_method"),
		ExpiresAt:           time.Now().Add(10 * time.Minute),
		Used:                false,
	}
	as.storeAuthCode(code, authCode)
 
	// Redirect back to the client with the code and state
	state := r.URL.Query().Get("state")
	redirectURL := fmt.Sprintf("%s?code=%s&state=%s", redirectURI, code, state)
	http.Redirect(w, r, redirectURL, 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
	}
 
	// 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":   authCode.RedirectURI,
		"iat":   time.Now().Unix(),
		"exp":   time.Now().Add(1 * time.Hour).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:    3600,
		TokenType:    "Bearer",
	}
 
	w.Header().Set("Content-Type", "application/json")
	json.NewEncoder(w).Encode(response)
}

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")
	json.NewEncoder(w).Encode(response)
}

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[RFC 6749, 2012].

// /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 err != nil {
        http.Error(w, "invalid_grant", http.StatusBadRequest)
        return
    }
 
    // CRITICAL: presented token has already been used (rotated)
    if rt.RotatedAt != nil {
        // Theft detection: a revoked token was replayed.
        // Revoke EVERY token issued to this user from this client.
        _ = store.RevokeFamily(r.Context(), rt.UserID, rt.ClientID)
        slog.Warn("refresh-token replay detected; family revoked",
            "user", rt.UserID, "client", rt.ClientID)
        http.Error(w, "invalid_grant", http.StatusBadRequest)
        return
    }
 
    // Issue a new access token + new refresh token
    newAT := signAccessToken(rt.UserID, rt.ClientID, rt.Scopes, 15*time.Minute)
    newRT := generateRefreshToken()
 
    if err := store.RotateRefreshToken(r.Context(), rt.ID, newRT, 30*24*time.Hour); err != nil {
        http.Error(w, "server_error", http.StatusInternalServerError)
        return
    }
 
    json.NewEncoder(w).Encode(map[string]any{
        "access_token":  newAT,
        "refresh_token": newRT,
        "token_type":    "Bearer",
        "expires_in":    900,
    })
}

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 in < 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 endpoint is cached (Cache-Control: max-age=86400) and signed responses are not cached
  • 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 — this is what the OAuth2 spec calls "automatic reuse detection":

func (s *TokenServer) Refresh(ctx context.Context, presented string) (*TokenPair, error) {
    rt, err := s.repo.GetByID(ctx, presented)
    if err != nil { return nil, ErrInvalidGrant }
 
    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.repo.RevokeFamily(ctx, rt.FamilyID)
        s.audit.Log(ctx, AuditEvent{
            Type:     "refresh_token_replay",
            FamilyID: rt.FamilyID,
            UserID:   rt.UserID,
        })
        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);

The single most common mistake teams make on a hand-rolled IdP: skipping the audit log because "we'll add it later when we have a use case." The use case is always the same — six months in, a customer reports 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 accepts a token plus an optional token_type_hint and returns 200 OK whether the token existed or not. The response is deliberately uniform so an attacker probing for valid tokens cannot distinguish "revoked successfully" from "never existed." Teams new to the spec usually get this wrong — they return 404 for unknown tokens and turn the endpoint into an 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. Crucially it never branches its response on whether the token was found:

// RevokeEndpoint implements RFC 7009. Always returns 200 to avoid
// leaking whether the token exists.
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) {
        // 401 is the only error the spec permits — auth failure.
        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 {
        if rt.Kind == KindRefresh && rt.ClientID == clientID {
            _ = s.repo.RevokeFamily(r.Context(), rt.FamilyID)
        } else if rt.Kind == KindAccess && rt.ClientID == clientID {
            _ = s.repo.RevokeAccessToken(r.Context(), rt.ID)
        }
        s.audit.Log(r.Context(), AuditEvent{
            Type: "token_revoked", FamilyID: rt.FamilyID,
            ClientID: clientID, UserID: rt.UserID,
        })
    }
 
    // Always 200 — even if the token didn't exist or belonged to another client.
    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 cache that resource servers consult on a sampled basis. The schema below holds the cache; 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, easily cached for the token's remaining TTL. 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

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