VibeWeek
Home/Grow/OAuth Provider Implementation — Chat Prompts

OAuth Provider Implementation — Chat Prompts

⬅️ Back to 6. Grow

If you're a SaaS in 2026 and have customers asking "can I let third-party apps connect to my account in your product?" — Zapier, Make, your customer's internal tools, integration partners, your own mobile app calling your API — you need an OAuth 2.1 authorization server. NOT social-login (that's where you're a consumer of someone else's OAuth, like "Sign in with Google"). This is the inverse: where YOU are the identity authority, and external apps integrate with you. Different mental model, different threat model, different code paths, different governance.

Get this right and you unlock an integration ecosystem (every Zapier/Make/n8n integration starts here), enterprise integrations (custom apps the customer builds), partner integrations (Salesforce, Slack, Notion talking to your product), and your own mobile/desktop clients without storing passwords. Get it wrong and you ship a critical security vulnerability that ships to every customer simultaneously — token theft, scope escalation, refresh-token abuse, redirect-URI injection, or PKCE bypass — any of which lands you in the security advisory bulletin.

This chat walks through implementing a production-grade OAuth 2.1 authorization server: scopes, OAuth client registration, the auth code + PKCE flow, refresh tokens, token introspection, revocation, the consent screen UX, secrets storage, audit, and the inevitable migration to OIDC for sign-in scenarios.

What you're building

  • OAuth 2.1 authorization server (RFC 6749 + RFC 7636 PKCE + draft OAuth 2.1 hardening)
  • Client registration model (public + confidential clients)
  • Scope catalog (granular permissions)
  • Authorization code flow + PKCE (the only flow you should support for new apps)
  • Refresh tokens with rotation
  • Access tokens (signed JWT or opaque) with introspection endpoint
  • Token revocation endpoint
  • Consent screen UX (granular, scope-explainable, branded)
  • Audit log of grants, token use, revocations
  • Admin UI for end-users to manage their connected apps
  • Optional: OIDC layer on top (id_tokens for sign-in scenarios)

1. Decide the scope catalog FIRST

Before writing any code, design the OAuth scope catalog.

This is the single most-load-bearing decision in the project. Scopes you ship are scopes you can never remove without breaking integrations. Be conservative.

Scope design principles:

1. Scopes are nouns + verbs: `docs:read`, `tasks:write`, `users:admin`
2. NOT roles: don't ship `admin` scope; ship `users:admin` (specific)
3. Hierarchical: `docs:write` implies `docs:read`; document this
4. Read vs write distinction always
5. Sensitive resources get their OWN scope (e.g. `billing:read` separate from `account:read`)
6. Cross-account / cross-workspace scopes flagged explicitly (require extra consent UI)
7. Versioned only if you ABSOLUTELY must (avoid; expand the scope's meaning instead)

Example catalog for a typical SaaS:

Read scopes:
- account:read       — user profile, name, email
- workspaces:read    — list of workspaces user belongs to
- docs:read          — read documents
- docs:metadata      — list docs without content (lighter-weight)
- comments:read
- tasks:read
- members:read       — workspace member list
- billing:read       — subscription, invoices (sensitive!)

Write scopes:
- docs:write         — create + update documents
- docs:delete        — soft-delete documents (separate from write)
- tasks:write
- comments:write
- members:write      — invite/remove (sensitive!)
- workspaces:admin   — workspace settings (sensitive!)
- billing:write      — change subscription (sensitive!)

Special:
- offline_access     — request a refresh token (so app can act when user is offline)
- openid             — OIDC sign-in (request id_token; user identity assertion)
- email, profile     — OIDC standard scopes

Explicitly NOT shipping:
- admin               — too broad
- *                   — never; deny-all wildcards
- delete              — too broad
- root                — never

Document for each scope:
- What it allows
- What it explicitly does NOT allow
- One-line user-facing description (shown in consent UI)
- Whether it's "sensitive" (requires extra confirmation step)
- What scopes it implies (e.g., `docs:write` implies `docs:read`)

Provide me:
1. The scope catalog as a TypeScript const
2. The consent-screen-friendly descriptions
3. A scopeImplies(scope) function
4. A check function: hasScope(token, requestedScope) including hierarchy

Show me the type:

const SCOPES = {
  'account:read': {
    description: 'Read your profile information (name, email)',
    sensitive: false,
    implies: [],
  },
  'docs:read': {
    description: 'Read your documents',
    sensitive: false,
    implies: [],
  },
  'docs:write': {
    description: 'Create and edit your documents',
    sensitive: false,
    implies: ['docs:read'],
  },
  'billing:read': {
    description: 'View your subscription and invoices',
    sensitive: true,
    implies: [],
  },
  // ...
}

Output: an opinionated, hierarchical scope catalog you can defend in 5 years.

2. Design the OAuth client + token data model

Now design the persistence layer.

Schema (Postgres + Drizzle/Prisma):

oauth_clients (
  id                 uuid pk
  workspace_id       uuid  -- which of OUR workspaces created this client (for first-party + customer-built integrations)
  client_id          text unique not null  -- random ~32 chars; safe to expose
  client_secret_hash text  -- argon2id; null for public clients (PKCE-only)
  client_type        text not null  -- 'confidential' | 'public'
  name               text not null  -- shown to end-users in consent UI
  description        text
  logo_url           text
  homepage_url       text
  redirect_uris      text[] not null  -- exact-match enforcement; min 1
  allowed_scopes     text[] not null  -- subset of catalog the client may REQUEST
  default_scopes     text[]  -- pre-checked in consent UI
  created_by         uuid  -- user who registered the client
  created_at         timestamptz
  status             text  -- 'active' | 'suspended' | 'pending_review'
  is_first_party     bool default false  -- our own apps (skip consent? maybe)
  approved_at        timestamptz  -- review/approval workflow
  approved_by        uuid
)

Indexes:
- UNIQUE on client_id
- (workspace_id) for listing
- (status) for admin queries

oauth_authorizations (
  id                  uuid pk
  user_id             uuid not null  -- the resource owner who consented
  client_id           text not null
  scopes              text[] not null  -- granted scopes
  granted_at          timestamptz
  revoked_at          timestamptz
)

UNIQUE INDEX (user_id, client_id) — one row per (user, client); update on re-consent.

oauth_authorization_codes (
  code_hash           text pk  -- sha256 of the code (we never store raw)
  client_id           text not null
  user_id             uuid not null
  redirect_uri        text not null  -- must match exactly on token exchange
  scopes              text[] not null
  code_challenge      text  -- PKCE
  code_challenge_method text  -- 'S256' (only S256 in 2026)
  nonce               text  -- for OIDC
  expires_at          timestamptz not null  -- 60s
  used_at             timestamptz  -- single-use enforcement
  created_at          timestamptz
)

oauth_access_tokens (
  id                  uuid pk
  token_hash          text unique not null  -- sha256; we don't store the raw token
  client_id           text not null
  user_id             uuid not null
  scopes              text[] not null
  expires_at          timestamptz not null  -- typically 1 hour
  revoked_at          timestamptz
  created_at          timestamptz
  last_used_at        timestamptz  -- denormalized counter for "active sessions"
)

Indexes:
- UNIQUE on token_hash
- (client_id, user_id) for revocation queries
- (expires_at) for cleanup

oauth_refresh_tokens (
  id                  uuid pk
  token_hash          text unique not null
  client_id           text not null
  user_id             uuid not null
  scopes              text[] not null
  expires_at          timestamptz  -- typically 30-90 days
  rotated_to_id       uuid  -- when rotated, points to new token (for replay detection)
  used_at             timestamptz  -- detects replay
  revoked_at          timestamptz
  created_at          timestamptz
)

Indexes:
- UNIQUE on token_hash
- (client_id, user_id) for revocation
- (rotated_to_id) for chain queries

oauth_audit_log (
  id          uuid pk
  event       text  -- 'authorization_code_issued', 'token_exchanged', 'token_introspected', 'token_revoked', 'refresh_rotated', 'refresh_replayed' (alarm!), 'consent_revoked', etc.
  client_id   text
  user_id     uuid
  ip_hash     text
  user_agent  text
  details     jsonb
  created_at  timestamptz
)

Why store hashes not raw tokens?
- Defense-in-depth: DB compromise doesn't expose live tokens
- Auth check: hash incoming token, look up; constant-time compare not required because the hash itself is the index key

Why separate authorization codes table?
- Codes are short-lived (60s) and single-use
- High write/read volume; clean up aggressively

Walk me through:
1. The migration SQL
2. Drizzle/Prisma schema
3. RLS policies if applicable
4. The cleanup job for expired codes/tokens (every 5 min)
5. Why I should NOT use raw SQL strings for token storage; constants only

Output: a defensible schema you can audit.

3. Implement the authorization endpoint (start of the flow)

Now implement /oauth/authorize — the user-facing endpoint where consent happens.

Flow:
1. Third-party app redirects user to:
   GET /oauth/authorize?
     response_type=code&
     client_id=abc123&
     redirect_uri=https://thirdparty.app/callback&
     scope=docs:read+docs:write&
     state=random_csrf_token&
     code_challenge=base64url(sha256(code_verifier))&
     code_challenge_method=S256

2. Our server:
   a. Validate query params (response_type=code; reject others)
   b. Look up client_id; ensure status=active
   c. Validate redirect_uri EXACT match against client.redirect_uris (NO wildcards, NO scheme variation)
   d. Validate requested scopes are subset of client.allowed_scopes
   e. Validate PKCE: code_challenge present, method=S256
   f. Validate user is authenticated; if not, redirect to login with return URL = current authorize request
   g. Check existing authorization (oauth_authorizations) for this (user_id, client_id):
      - If exists with all requested scopes already granted: skip consent, issue code immediately (silent re-auth)
      - If exists but missing some: show only the new scopes in consent
      - If not: full consent screen
   h. Show consent screen with: client logo, name, requested scopes (human-readable), "Allow" / "Deny"
   i. On Allow:
      - Generate authorization code (32 bytes, base64url)
      - Hash + store in oauth_authorization_codes with all flow data + 60s expiry
      - Update oauth_authorizations row (or insert)
      - Redirect to redirect_uri with code + state
   j. On Deny:
      - Redirect with ?error=access_denied&state=...
      - Audit log

CRITICAL security validations:
- redirect_uri EXACT match (RFC 6749 § 4.1.2.1)
  - "https://thirdparty.app/callback" only matches "https://thirdparty.app/callback" — NOT subpaths, NOT query strings
  - Many real-world OAuth bugs come from substring matching
- response_type MUST be 'code' (no implicit flow; deprecated)
- PKCE code_challenge required (no exceptions in 2026)
- code_challenge_method MUST be 'S256' (no 'plain')
- state echoed back unchanged (CSRF protection on client side)
- All security failures: redirect with ?error=... unless redirect_uri itself is invalid (then render error page)

Implement:
1. The /oauth/authorize handler (Next.js API route or your stack)
2. The consent screen component (separate page; renders requested scopes)
3. The login redirect for unauthenticated users
4. The "silent re-auth" path for already-granted scopes
5. The audit log writes for every branch

Show me the handler with tight type-safety on the input params (zod validation). Show me how the consent screen GETs the scope-description map.

Anti-patterns to call out:
- Don't trust query params; validate everything
- Don't skip PKCE because "the client is confidential"; PKCE in 2026 is universal
- Don't approve consent before showing the screen
- Don't allow same-domain redirect_uris by default ("but it's our domain") — exact match only
- Don't render the consent screen with the client's HTML embedded; XSS risk

Output: the authorize endpoint that won't end up on hackernews for the wrong reasons.

4. Implement the token endpoint (code → tokens)

Now implement /oauth/token — where the third-party app exchanges the code for tokens.

Flow:
1. Third-party POSTs to /oauth/token (NEVER GET; tokens go in body):
   Content-Type: application/x-www-form-urlencoded
   Body:
     grant_type=authorization_code
     code=<the_code>
     redirect_uri=<must_match>
     client_id=<...>
     client_secret=<for_confidential_clients_only>
     code_verifier=<the_PKCE_verifier>

2. Server:
   a. Authenticate client:
      - Confidential client: client_id + client_secret (compare argon2 hash)
      - Public client: client_id only (PKCE proves possession)
   b. Look up authorization code (hash it; query oauth_authorization_codes by hash)
   c. Validate:
      - Code exists, not used, not expired (within 60s)
      - client_id matches the code's client_id
      - redirect_uri matches the code's stored redirect_uri (EXACT)
      - PKCE: sha256(code_verifier) base64url == stored code_challenge
   d. Mark code as used (atomic UPDATE WHERE used_at IS NULL)
   e. Issue tokens:
      - Access token: opaque random 32 bytes OR signed JWT (decision below)
      - Refresh token: opaque random 32 bytes (always opaque; rotation requires server lookup)
      - Store hashes in DB
   f. Return:
      {
        "access_token": "...",
        "token_type": "Bearer",
        "expires_in": 3600,
        "refresh_token": "...",
        "scope": "docs:read docs:write"
      }

DECISION: opaque tokens or JWT access tokens?

OPAQUE:
+ Easy to revoke (single DB row)
+ Small token size
+ No JWS signing complexity
+ Token introspection still works (call /oauth/introspect)
- Each API call requires DB lookup
- Doesn't work well for distributed systems where receiver can't reach issuer

JWT (signed access tokens):
+ Stateless verification (just verify signature)
+ Carries claims (user_id, scopes) in token
+ Good for microservices
- Hard to revoke before expiry (need short expiry + revocation list)
- Larger token size (200-500 bytes vs 30 bytes)
- JWS complexity (key rotation, JWKS endpoint)

DEFAULT FOR MOST SAAS IN 2026: opaque tokens with introspection endpoint.

If you're at scale with microservices, switch to JWT later. Don't optimize prematurely.

Implement:
1. The /oauth/token handler (with constant-time client_secret comparison)
2. The PKCE verifier check (timing-safe)
3. Atomic code-consumption (single SQL UPDATE; reject if already used)
4. Token generation + hash storage
5. Error responses per RFC 6749 (invalid_grant, invalid_client, etc.)

Errors must follow OAuth spec:
- HTTP 400 with JSON: { "error": "invalid_grant", "error_description": "..." }
- error must be one of: invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, invalid_scope
- Don't leak internal errors; map to spec codes

Refresh token rotation rule:
- Every refresh token use issues a NEW refresh token
- The old refresh token is marked used + linked to the new one
- If a USED refresh token is presented again — that's a replay attack; revoke entire token chain + alert

Show me:
1. The handler
2. The token generator
3. The refresh-token rotation logic
4. The replay detection
5. The error response builder

Output: the bedrock token endpoint.

5. Implement refresh tokens with rotation + replay detection

Refresh tokens are the most-attacked surface. Get this right.

Flow:
POST /oauth/token
  grant_type=refresh_token
  refresh_token=<value>
  client_id=<...>
  client_secret=<for confidential>

Server:
1. Authenticate client
2. Hash refresh_token; look up in oauth_refresh_tokens
3. Validate:
   a. Token exists
   b. Token not revoked
   c. Token not expired
   d. client_id matches
   e. Token's used_at IS NULL — CRITICAL
4. If used_at IS NOT NULL: REPLAY DETECTED
   - Revoke entire token chain (rotated_to_id chain back to root)
   - Revoke all access tokens issued from this chain
   - Email user: "We detected suspicious activity on your [App] integration. Please re-authorize."
   - Audit-log alarm
   - Return invalid_grant
5. Otherwise:
   a. Mark old refresh as used (atomic UPDATE)
   b. Generate new refresh token + new access token
   c. Set old.rotated_to_id = new.id
   d. Return new pair

Replay detection mechanic:
- Token chain: oauth_refresh_tokens.rotated_to_id forms a linked list
- A used token presented again → middleman has it
- The fresh token is also out there with the legitimate client
- We can't distinguish; assume compromise; revoke chain

Refresh token expiry:
- Default 30 days (sliding; each rotation extends)
- Sensitive integrations: 7 days
- Long-lived integrations: 90 days
- Inactive (no use in 30 days): expire

Reuse-detection example:
attacker steals refresh_token A.
attacker uses A → gets B (new refresh token + access token)
later, the legitimate client uses A → REPLAY (used_at is not null)
server revokes A, B, and all descendants.
both attacker and legitimate client now broken.
legitimate client re-authorizes; attacker's tokens are dead.

This is by design. Forces re-auth but kills the attacker.

Implement:
1. The refresh handler
2. The chain-revocation function (recursive query in Postgres CTE)
3. The user-notification email
4. The metrics for replay detection rate
5. The cleanup of expired refresh tokens

Anti-patterns:
- "Refresh tokens that never expire" — no
- "Don't rotate; just verify" — no, this defeats replay detection
- "Sliding expiry without rotation" — better than nothing but still inferior
- "Public clients get the same refresh tokens as confidential" — actually fine in 2026, as long as PKCE was used originally

Output: refresh tokens that survive contact with attackers.

6. Implement the introspection + revocation endpoints

Now implement two adjacent endpoints.

POST /oauth/introspect (RFC 7662)
- For our own backend services to validate opaque tokens
- Authenticated by client credentials OR internal-network mTLS
- Body: token=<value>
- Response (active token):
  {
    "active": true,
    "client_id": "...",
    "user_id": "...",
    "scopes": ["docs:read", "docs:write"],
    "exp": 1234567890,
    "iat": 1234564290
  }
- Response (inactive):
  { "active": false }
- Cache aggressively in calling services (60s TTL is fine)

POST /oauth/revoke (RFC 7009)
- For third-party apps to revoke their own token
- For end-user-initiated "disconnect this app" flows
- Body: token=<value>&token_type_hint=access_token|refresh_token
- Server:
  a. Authenticate the client
  b. Look up token (try both tables based on hint, then both regardless)
  c. Mark revoked_at (don't delete; audit)
  d. If refresh token: revoke entire chain
  e. If access token: just that token
  f. Always return 200 OK (don't leak whether token existed)

Implement:
1. /oauth/introspect handler
2. /oauth/revoke handler
3. Caching strategy for introspection (Redis with 60s TTL)
4. The "disconnect this app" UI button (see next section)

Performance note: introspection becomes a hot path. Cache with Redis or move to JWT access tokens if introspection RPS exceeds ~5K/s.

Output: the supporting endpoints that make the system real.

7. Build the user-facing "Connected Apps" UI

End users need to see + manage what apps they've authorized. Build this UI.

Page: /settings/connected-apps

Layout:
- List of authorized apps (rows)
- Each row: app logo, name, granted-scopes summary, "Last used X days ago", "Disconnect" button
- Filter: active / revoked / all
- Empty state: "You haven't connected any third-party apps yet. Browse our integrations directory →"

For each authorization:
- Click row → detail view
  - Full scope list with descriptions
  - Activity log (last 90 days of API usage by this client)
  - Revoke button (confirm modal)
  - Re-authorize-with-different-scopes button (rare)

Backend:
- Query oauth_authorizations WHERE user_id = ? AND revoked_at IS NULL
- Join oauth_clients for app metadata
- Aggregate last_used_at from access tokens for the "last used" timestamp

Disconnect flow:
- Soft-revoke the oauth_authorizations row (set revoked_at)
- Revoke all access tokens for this (client_id, user_id)
- Revoke all refresh token chains
- Audit log
- Email confirmation: "You disconnected [App] from your [Product] account"

UX considerations:
- Show GRANTED scopes (not requested at consent time — they could differ if scopes have been updated)
- Render scope descriptions from current catalog (translates if scope evolved)
- Show app's HOMEPAGE_URL as link out
- For first-party apps, label them clearly ("Official [Product] mobile app")
- Don't allow revoking first-party apps without confirmation (it'll log them out)

Implement:
1. The /settings/connected-apps page (Server Component)
2. The detail view + activity log
3. The disconnect server action
4. The email confirmation

Output: a settings UI users actually trust.

8. Build the developer-facing "Create OAuth App" UI

Customers / partners / your own engineers need to register OAuth clients. Build this UI.

Page: /developer/oauth-apps

Capabilities:
- List user's owned OAuth apps
- "Create new app" button → form
- Click app → detail page with:
  - Edit metadata (name, description, logo, homepage)
  - Manage redirect URIs (add/remove)
  - Manage allowed scopes
  - Regenerate client_secret (for confidential clients)
  - View client_id (always visible)
  - View client_type (cannot be changed after creation)
  - Audit log of token issuances
  - Statistics (active users, recent activity)

Create-app form:
- Name (required, shown in consent UI)
- Description (optional)
- Logo URL (optional)
- Homepage URL (optional)
- Application type: 'Web app (server-side)' / 'Single-page app or mobile' / 'CLI/Service'
  - Maps to confidential vs public client_type
- Redirect URIs (list; min 1; required)
  - Format validation (must be HTTPS for non-localhost)
  - At least one required
- Allowed scopes (multi-select from catalog)
  - Default scopes pre-checked

On submit:
- Generate client_id (32-char random)
- For confidential: generate client_secret (one-time-show!), hash + store
- Show client_id + client_secret on success page
- WARNING: "Save your client_secret now — we'll never show it again"

Edit-app:
- All metadata editable
- Redirect URIs editable (changes apply immediately)
- Scope removals → existing tokens with that scope keep working until refresh (then drop)
- Regenerate secret → invalidates all access + refresh tokens (with warning)

Permissions:
- Only the creator + workspace admins can edit
- Soft-delete vs hard-delete decision: soft-delete; preserves audit trail

Approval workflow (optional, recommended):
- Apps requesting sensitive scopes (billing:write, members:write) flagged for review
- Admin reviews + approves before app goes live
- Status field: pending_review / approved / rejected / suspended

Implement:
1. The list + detail pages
2. The create-app form with validation
3. The secret-regeneration flow
4. The audit log view
5. The approval workflow (if scoped in)

Output: a developer surface that doesn't suck.

9. Layer OIDC on top (for sign-in scenarios)

OAuth 2.1 alone gives you authorization. For sign-in (where you want to identify the user to the third-party), add OIDC (OpenID Connect).

OIDC adds:
- An `id_token` returned alongside `access_token` (signed JWT with user identity claims)
- The `openid` scope triggers id_token issuance
- The `profile`, `email` standard scopes add claims (name, email)
- A discovery endpoint: /.well-known/openid-configuration
- A JWKS endpoint: /.well-known/jwks.json (your public keys)
- A UserInfo endpoint: /oauth/userinfo (returns user claims given an access token)

Implementation steps:
1. Generate an RSA or EdDSA keypair (rotate annually; keep last 2 keys for verification)
2. Publish JWKS at /.well-known/jwks.json
3. Publish OpenID discovery at /.well-known/openid-configuration
4. When `openid` scope present in request: also issue id_token
5. id_token claims:
   - iss (your issuer URL)
   - sub (stable user identifier — DON'T leak email; use UUID)
   - aud (client_id)
   - exp (token expiry; ~10 min)
   - iat (issued at)
   - nonce (echo back from authorize request — anti-replay)
   - email, email_verified, name (if scopes granted)
6. Sign with current key; include kid in header
7. Implement /oauth/userinfo:
   - Bearer <access_token>
   - Returns claims based on scopes

Decisions:
- Use RS256 (RSA-SHA256) — most-supported. Or EdDSA if you're confident in client support.
- Don't use HS256 (symmetric) — sharing the secret with all clients is wrong shape
- Rotate signing keys yearly; keep prior key in JWKS for ~6 months for in-flight tokens

Implement:
1. The keypair generator + storage (KMS / Vault / env-var-encrypted)
2. The JWKS endpoint
3. The discovery endpoint
4. The id_token generator
5. The /oauth/userinfo endpoint
6. The UI flow if you offer "Sign in with [Your Product]" as an option

Note: "Sign in with [Your Product]" is a different product feature from "third-party apps integrate with my data." Don't conflate them. OIDC enables the first; OAuth enables the second; they share infrastructure.

Output: the OIDC layer that turns this into a full identity provider.

10. Audit + abuse prevention

Hardening checklist:

Rate limits:
- /oauth/authorize: per-IP rate limit (60/min)
- /oauth/token grant=authorization_code: per-client rate limit (60/min)
- /oauth/token grant=refresh_token: per-token rate limit (5/min — refreshes shouldn't be that frequent)
- /oauth/introspect: per-client (1000/min — high since backends call it)
- /oauth/revoke: per-client (60/min)

Per-client and per-user visibility:
- Dashboard for app developers showing recent token activity
- Anomaly detection: client suddenly using 10x usual scopes → flag
- Geographic anomaly: token used from country never seen before → notify user

Brute force protection:
- Failed client_secret presentation: rate-limit + lockout
- Failed code presentation: rate-limit by client_id
- Token enumeration: tokens are 256-bit; brute force not practical

Logging (everything):
- Authorization request (who, what client, what scopes)
- Consent given/denied
- Token issued (for what scopes)
- Token used (for what API call)
- Token refreshed
- Token revoked
- Replay detected (alarm)
- Failed auth (failed code, secret, etc.)

Rotation policies:
- Signing keys: yearly (keep last 2)
- Long-lived tokens: refresh expiry rotates on use
- Static secrets (client_secret): admin can rotate; old expires after 24h grace

Endpoint security:
- TLS 1.2+ only; redirect HTTP → HTTPS
- HSTS header
- All endpoints CORS-locked (never `Access-Control-Allow-Origin: *`)
- Token endpoint rejects GET (only POST)
- Revoke endpoint never returns whether token existed (privacy)

Defense-in-depth:
- DB encryption at rest (table-level for token tables minimum)
- Client_secret stored as argon2id hash (NEVER plaintext)
- Tokens stored as sha256 hashes (NEVER plaintext)
- Signing keys in HSM/KMS if at all possible

Implement:
1. The rate limit middleware (use [Upstash Ratelimit / your lib])
2. The anomaly detection (cron + alerts)
3. The logging schema + ingestion
4. The dashboard for developers
5. The geographic-anomaly notification flow

Output: the layers that catch what the spec doesn't.

11. Edge cases + operational concerns

Walk me through edge cases I'll hit:

1. Client lost their client_secret
   - Provide regenerate flow; old secret invalid after 24h grace period
   - Email confirmation required

2. User changes email on our side
   - Existing tokens still work (sub in id_token is UUID, not email)
   - Email claim in next token issuance reflects new value
   - userinfo returns current value

3. User deleted their account
   - All authorizations revoked
   - All tokens revoked
   - Token introspection returns active=false
   - id_token issued before deletion still validates (signature good) — relying parties should call userinfo

4. Workspace membership changes
   - User leaves workspace; tokens for that workspace context should revoke
   - Implementation: workspace_id in token, scope check at API boundary

5. Scope removed from catalog (deprecation)
   - Existing tokens with that scope keep working until refresh
   - On refresh, scope is dropped
   - Deprecate publicly; give 6+ months notice

6. Client compromised / removed from app store
   - Suspend client (status = 'suspended')
   - All tokens revoked
   - User contacted

7. Mass user-revocation event (e.g. you discover an integration was malicious)
   - Bulk revoke all authorizations + tokens for client_id
   - Email all affected users

8. id_token used after issuance but signing key rotated
   - JWKS endpoint publishes both old + new keys for grace period
   - Verifier uses kid in token header to pick correct key

9. Refresh token expiration hit (legitimate user, app silent for months)
   - Refresh fails with invalid_grant
   - App must trigger re-auth flow
   - User sees consent screen again

10. PKCE code_verifier mismatch
    - Almost always app bug or attack
    - Fail with invalid_grant
    - Audit-log; rate-limit at high failure rate

11. Authorization code intercepted but PKCE prevents use
    - This is exactly what PKCE was designed to defeat
    - Attacker has code but not code_verifier
    - Code expires in 60s; attacker has no ability to exchange

12. App requests scope user doesn't have permission to grant
    - Example: app requests members:write but user is not workspace admin
    - Show consent screen with the scope marked "you cannot grant this"
    - Or block before consent screen with clear error
    - Don't silently strip the scope (confusing)

13. Native mobile app deep-link redirect
    - Use custom URI scheme (myapp://callback) or universal links
    - Custom schemes are technically RFC-allowed but less secure
    - Universal links / app links preferred (verified domain ownership)

14. CLI app needs to authorize without browser
    - Use Device Authorization Grant (RFC 8628)
    - User visits URL on phone; app polls for completion

For each, walk me through the code change + UX impact + comms required.

Output: a system that survives real-world conditions.

12. Recap

What you've built:

  • Scope catalog (granular, hierarchical, reviewed)
  • OAuth client registration model + UI
  • /oauth/authorize endpoint with PKCE + consent UI
  • /oauth/token endpoint (auth code → tokens)
  • Refresh token rotation with replay detection
  • Access token introspection endpoint
  • Token revocation endpoint
  • Connected-apps user UI
  • OIDC layer (id_token, JWKS, discovery, userinfo)
  • Rate limiting + anomaly detection
  • Audit logging
  • Operational runbooks for compromise scenarios

What you're explicitly NOT shipping in v1:

  • Implicit flow (deprecated; never)
  • Resource Owner Password Credentials grant (deprecated; never)
  • JWT-Secured Authorization Request (JAR) (advanced; v2)
  • Pushed Authorization Requests (PAR) (advanced; v2)
  • mTLS client authentication (enterprise feature; v2)
  • Token Binding (browser support uneven; skip)
  • DPoP (Demonstrating Proof of Possession) (advanced; v3)
  • Dynamic Client Registration (RFC 7591) (only if Salesforce-tier ecosystem)
  • Federated identity (multi-IdP; v2 if needed)

The spec is huge. Ship the 90% case (auth code + PKCE + refresh + introspection + revocation + OIDC) right. Layer advanced features only when a customer asks.

The biggest mistake teams make: shipping OAuth without PKCE because "we'll add it later." Add PKCE from day one. Public + confidential clients both. Always. No exceptions.

The second mistake: storing access tokens in plaintext. ALWAYS hash before storage. The DB is not your boundary; the application layer is.

The third mistake: skipping refresh-token rotation. The chain-revocation pattern is what makes refresh tokens safe long-lived. Implement it from day one.

See Also