Build Your Own OAuth Provider: Tokens, Scopes, and Consent Screens Done Right

Most teams reach for Keycloak or Auth0 without thinking twice. That works until you’re hit with a $40k/month SaaS bill, a compliance audit that requires you to own the token store, or a product decision that forces you to federate auth across three unrelated services you actually control. At that point, knowing how OAuth providers work under the hood stops being academic.

This article walks you through designing an OAuth 2.1 authorization server from the ground up — how tokens are structured, how scopes should be designed (not just bolted on), and how to build a consent screen that isn’t a dark-pattern nightmare. We’re not building a toy. We’re building something you could actually run in production.

For a battle-tested open-source baseline to study or extend, look at Ory Hydra on GitHub — it’s the closest thing to a reference implementation of a headless OAuth 2.1 server.


What OAuth 2.1 Actually Changes

OAuth 2.0 was published in 2012 and the spec was deliberately loose. That looseness gave us a decade of footguns: implicit flow leaking tokens in URL fragments, response_type=token over non-HTTPS, clients skipping PKCE, and wildcard redirect URIs that made phishing trivially easy.

OAuth 2.1 (still in draft, but widely adopted) consolidates the best practices:

  • PKCE is mandatory for all clients, including confidential ones. No exceptions.
  • Implicit flow is removed. Gone. Dead. Use authorization code + PKCE.
  • Redirect URIs must be exact matches. No partial matching, no wildcards in production.
  • Refresh tokens for public clients must be sender-constrained or rotated on every use.

Design your provider to these rules from day one. Retrofitting is miserable.


Token Architecture

Authorization Codes

The authorization code is a one-time use, short-lived (60–120 seconds max) credential that the client exchanges for tokens. It proves the user authorized the client at the authorization endpoint.

Store it server-side. A simple authorization_codes table works fine:

CREATE TABLE authorization_codes (
    code        TEXT        PRIMARY KEY,         -- cryptographically random, 32+ bytes
    client_id   TEXT        NOT NULL,
    user_id     UUID        NOT NULL,
    redirect_uri TEXT       NOT NULL,
    scope       TEXT        NOT NULL,
    code_challenge       TEXT NOT NULL,          -- PKCE S256 challenge
    code_challenge_method TEXT NOT NULL DEFAULT 'S256',
    expires_at  TIMESTAMPTZ NOT NULL,
    used        BOOLEAN     NOT NULL DEFAULT FALSE
);

On exchange, verify the code hasn’t been used (used = FALSE), hasn’t expired, and that the code_verifier hashes to code_challenge. Then immediately mark it used — don’t delete it yet, so you can detect replay attacks and revoke issued tokens if a used code is re-presented (this is the "code reuse" attack detection pattern from RFC 6819).

Access Tokens

Two schools of thought: opaque (random strings, server-side lookup) vs structured (JWT, self-contained). Both are valid. Here’s the real tradeoff:

Opaque tokens mean every API resource server must call your token introspection endpoint (POST /oauth/introspect per RFC 7662). That’s a network hop per request. Fine for small deployments, painful at scale without caching.

JWTs are self-contained — resource servers validate the signature locally. No network hop. But: you can’t revoke them before expiry without a distributed blocklist. Short expiry (5–15 minutes) is your mitigation.

For most self-hosted setups, JWT access tokens with a short TTL and a revocation blocklist (Redis or a small DB table) is the sweet spot.

A minimal JWT payload for an access token:

{
  "iss": "https://auth.yourdomain.com",
  "sub": "user-uuid-here",
  "aud": ["api.yourdomain.com"],
  "client_id": "your-client-id",
  "scope": "openid profile email read:orders",
  "exp": 1748889600,
  "iat": 1748886000,
  "jti": "unique-token-id-for-revocation"
}

jti (JWT ID) is critical for revocation. When a user logs out or revokes access, you add the jti to a blocklist. Resource servers check against it. Without jti, you can’t revoke a JWT before it expires — you’re just hoping for the best.

Sign with RS256 (RSA) or ES256 (ECDSA, faster and smaller keys). Publish your public keys at /.well-known/jwks.json. Never use HS256 in a multi-service environment — symmetric signing means every service that validates tokens also has the power to forge them.

Refresh Tokens

Refresh tokens are long-lived (days to months) and must be treated like passwords. Store them hashed:

CREATE TABLE refresh_tokens (
    token_hash  TEXT        PRIMARY KEY,    -- SHA-256 of the actual token
    client_id   TEXT        NOT NULL,
    user_id     UUID        NOT NULL,
    scope       TEXT        NOT NULL,
    expires_at  TIMESTAMPTZ NOT NULL,
    rotated_at  TIMESTAMPTZ,                -- NULL = not yet rotated
    revoked     BOOLEAN     NOT NULL DEFAULT FALSE,
    family_id   UUID        NOT NULL        -- for refresh token rotation chains
);

family_id is the key to refresh token rotation. Every refresh operation issues a new token and revokes the previous one — but they share the same family_id. If a rotated (already-used) refresh token is presented again, your server knows the token was stolen (or the client is buggy), and should immediately revoke the entire family.

def handle_refresh(incoming_token: str) -> TokenResponse:
    token_hash = sha256(incoming_token)
    record = db.get_refresh_token(token_hash)

    if not record:
        raise InvalidGrant("unknown token")

    if record.revoked:
        # Token was already rotated — possible replay attack
        db.revoke_token_family(record.family_id)
        raise InvalidGrant("token reuse detected, family revoked")

    if record.expires_at < now():
        raise InvalidGrant("token expired")

    # Issue new access token and refresh token
    new_refresh = generate_token()
    db.rotate_refresh_token(
        old_hash=token_hash,
        new_hash=sha256(new_refresh),
        family_id=record.family_id
    )

    return TokenResponse(
        access_token=issue_jwt(record.user_id, record.scope),
        refresh_token=new_refresh,
        expires_in=900
    )

Gotcha: Don’t forget to handle the race condition where two refresh requests arrive simultaneously with the same token (network retry). A database unique constraint on token_hash and a FOR UPDATE lock on the row prevents double-rotation. If the second request hits after rotation, it’ll see revoked=TRUE and trigger the family revocation — which might be overly aggressive for a legitimate retry. Consider a short window (e.g., 30 seconds) where a rotated token is in a "recently rotated" state before triggering full family revocation.


Scope Design

Scopes are where most OAuth providers get it wrong. They either go too granular (50 scopes nobody can remember) or too coarse (admin scope that grants everything).

The Right Mental Model

A scope is a promise the authorization server makes to both the user and the resource server:

  • To the user: "This is what you’re granting access to."
  • To the resource server: "Tokens with this scope can perform these operations."

Design scopes around user-visible capabilities, not internal API methods. read:orders is a good scope. GET:/api/v2/orders is a terrible scope.

Scope Namespacing

Use a consistent naming convention. Two patterns work well:

Action:resource (GitHub-style):

read:profile
write:profile
read:repositories
delete:repositories
admin:org

service:resource:action (for multi-service setups):

billing:invoices:read
billing:payment_methods:write
shipping:orders:read

Pick one and stick to it. Mixing conventions is how you end up with an undocumented scope taxonomy that breaks when someone new joins the team.

Reserved OIDC Scopes

If you’re implementing OpenID Connect on top of OAuth (you should be — raw OAuth 2 is for machines, OIDC adds the user identity layer), these scopes have defined meanings:

  • openid — mandatory for OIDC, triggers ID token issuance
  • profile — name, picture, locale, etc.
  • email — email address and verification status
  • address — physical address (rarely used)
  • phone — phone number

Don’t redefine these. Don’t put your own data in the ID token fields these scopes control.

Scope Downscoping

Your token endpoint should support requesting a subset of an already-granted scope. A client might have read:profile write:profile authorized, but for a specific operation want to request a token with only read:profile. This is called downscoping and it’s good security hygiene — principle of least privilege at the token level.

POST /oauth/token
Content-Type: application/x-www-form-urlencoded

grant_type=refresh_token&
refresh_token=xxx&
scope=read%3Aprofile

The returned access token should have only the requested scope, as long as it’s a subset of what’s already authorized.

Gotcha: Never allow a refresh token exchange to expand scope. If the client requests a scope that wasn’t in the original authorization, reject it with invalid_scope. This seems obvious but gets missed in implementations that don’t validate scope against the original grant.


The consent screen is the moment the user decides whether to trust a client with their data. Get it wrong and you’re either leaking user trust through dark patterns or blocking legitimate access with a UX that’s confusing enough that users click "deny" on valid requests.

What Must Be On Screen

At minimum, every consent screen must show:

  1. Who is asking — the client’s registered name and logo, not just a client ID
  2. What they’re asking for — human-readable descriptions of each scope, not the technical scope string
  3. What they can’t do — equally important, and often skipped
  4. For how long — if the client is requesting offline access (refresh tokens), say so explicitly

A bad scope display:

This app is requesting: read:profile, write:orders, offline_access

A good scope display:

Delivery Tracker wants to:
  ✓ View your name and profile picture
  ✓ Create and update orders on your behalf
  ✓ Access your account even when you're not using the app

  ✗ Access your payment methods
  ✗ View your account settings

The "can’t do" section builds trust. Users who see it are more likely to grant access because they feel informed, not steamrolled.

First-Party vs Third-Party Clients

Your own apps (the mobile app, the web dashboard) are first-party clients. Third-party clients are developers building on your platform.

Many providers skip the consent screen for first-party clients — this is acceptable if:

  • The client is registered with a first_party: true flag in your client registry
  • The client is a confidential client (has a secret) that you control
  • Users can still see and revoke the grant from their account settings

For third-party clients: consent is never skippable on the first authorization. Period. Subsequent authorizations with an unchanged scope can re-use a cached grant (remember consent), but show a "you’ve already authorized this" notice rather than silently skipping.

Storing and Revoking Grants

After the user consents, store the grant:

CREATE TABLE oauth_grants (
    id          UUID        PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id     UUID        NOT NULL,
    client_id   TEXT        NOT NULL,
    scope       TEXT        NOT NULL,   -- space-separated, canonical ordered
    granted_at  TIMESTAMPTZ NOT NULL DEFAULT now(),
    last_used   TIMESTAMPTZ,
    UNIQUE (user_id, client_id)
);

Every user account needs a "Connected Apps" page that lists all active grants, when they were last used, and a single-click revoke button. No hoops, no confirmation dialogs for revocation. That’s RFC 7009 — token revocation — and it’s the user-facing version of it.

Gotcha: When a user revokes a grant, you need to revoke all associated refresh tokens and blocklist any active access tokens’ jti values. Just revoking the refresh token means the access token keeps working until it expires. For a 15-minute access token that’s acceptable in most threat models, but be explicit about it in your docs.

The "Remember This Decision" Checkbox

Don’t default it to checked. Don’t hide it. Let the user make an explicit choice about whether they want to skip consent on future authorizations from this client. If they check it, store the grant and skip the screen next time — but still validate that the requested scope hasn’t expanded.


Client Registration

Every client (web app, mobile app, CLI tool, third-party integration) must be registered before it can use your authorization server. At minimum, store:

CREATE TABLE oauth_clients (
    client_id       TEXT        PRIMARY KEY,
    client_secret_hash TEXT,               -- NULL for public clients
    name            TEXT        NOT NULL,
    description     TEXT,
    logo_url        TEXT,
    redirect_uris   TEXT[]      NOT NULL,  -- exact URIs, no wildcards
    allowed_scopes  TEXT[]      NOT NULL,
    grant_types     TEXT[]      NOT NULL DEFAULT ARRAY['authorization_code'],
    token_endpoint_auth_method TEXT NOT NULL DEFAULT 'client_secret_basic',
    is_first_party  BOOLEAN     NOT NULL DEFAULT FALSE,
    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
);

redirect_uris must be exact. When validating an authorization request, compare the provided redirect_uri against the registered list with a straight string equality check — no prefix matching, no domain-only matching, no wildcards.

Public clients (mobile apps, SPAs, CLIs) have no client_secret. They rely entirely on PKCE for security. Confidential clients (server-side web apps) have a secret and can use either client_secret_basic (HTTP Basic auth) or client_secret_post (in request body — less preferred).

Gotcha: Loopback redirect URIs (http://127.0.0.1:PORT/callback) for native apps are a special case. RFC 8252 says you should allow any port on loopback — because the OS assigns a random available port at runtime. Your validation logic needs to strip the port before comparing loopback URIs. This is the one legitimate exception to exact-match redirect URI validation.


The Discovery Document

Publish /.well-known/oauth-authorization-server (RFC 8414) and /.well-known/openid-configuration (if doing OIDC). Every modern client library uses these to auto-configure. Doing it manually is a maintenance nightmare.

{
  "issuer": "https://auth.yourdomain.com",
  "authorization_endpoint": "https://auth.yourdomain.com/oauth/authorize",
  "token_endpoint": "https://auth.yourdomain.com/oauth/token",
  "jwks_uri": "https://auth.yourdomain.com/.well-known/jwks.json",
  "revocation_endpoint": "https://auth.yourdomain.com/oauth/revoke",
  "introspection_endpoint": "https://auth.yourdomain.com/oauth/introspect",
  "scopes_supported": ["openid", "profile", "email", "read:orders"],
  "response_types_supported": ["code"],
  "grant_types_supported": ["authorization_code", "refresh_token", "client_credentials"],
  "code_challenge_methods_supported": ["S256"],
  "token_endpoint_auth_methods_supported": ["client_secret_basic", "client_secret_post", "none"]
}

Note what’s not in response_types_supported: token (implicit flow). Don’t advertise it. Don’t implement it.


Docker Compose Skeleton

Here’s a minimal production-ready stack — your authorization server, a Postgres database for persistent state, and Redis for the token blocklist and short-lived code storage:

# docker-compose.yml
services:
  authserver:
    image: your-auth-server:latest
    restart: unless-stopped
    environment:
      DATABASE_URL: "postgres://auth:${POSTGRES_PASSWORD}@db:5432/authdb?sslmode=require"
      REDIS_URL: "redis://redis:6379/0"
      ISSUER: "https://auth.yourdomain.com"
      # Key rotation: mount keys as secrets, not env vars
      SIGNING_KEY_PATH: "/run/secrets/rsa_private_key"
    secrets:
      - rsa_private_key
    ports:
      - "127.0.0.1:4000:4000"   # bind to loopback; Nginx/Caddy handles TLS
    depends_on:
      db:
        condition: service_healthy
      redis:
        condition: service_started
    healthcheck:
      test: ["CMD", "wget", "-qO-", "https://cd-linux.club:4000/health"]
      interval: 10s
      timeout: 5s
      retries: 3

  db:
    image: postgres:16-alpine
    restart: unless-stopped
    environment:
      POSTGRES_DB: authdb
      POSTGRES_USER: auth
      POSTGRES_PASSWORD: "${POSTGRES_PASSWORD}"
    volumes:
      - pg_data:/var/lib/postgresql/data
      - ./migrations:/docker-entrypoint-initdb.d  # run migrations on init
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U auth -d authdb"]
      interval: 5s
      timeout: 5s
      retries: 5

  redis:
    image: redis:7-alpine
    restart: unless-stopped
    command: redis-server --save 60 1 --loglevel warning
    volumes:
      - redis_data:/data

volumes:
  pg_data:
  redis_data:

secrets:
  rsa_private_key:
    file: ./secrets/rsa_private_key.pem

Never put private signing keys in environment variables. They show up in docker inspect, process listings, and logs. Mount them as files or use Docker secrets.


Production Checklist

Before you ship this anywhere real:

  • Key rotation plan: Your JWKS endpoint should serve 2–3 keys simultaneously during rotation. Old tokens signed with the previous key must remain valid until they expire. Never rotate keys by swapping — rotate by adding, then removing after the old key’s issued tokens have all expired.
  • Rate limiting on the token endpoint: The token endpoint is a target for credential stuffing. Rate limit by client IP, client ID, and user ID. A client that fails PKCE verification 10 times in 5 minutes should get a temporary ban.
  • Audit logging: Every authorization, every token issuance, every revocation. Include client_id, user_id, scope, ip, and user_agent. This is your forensics trail.
  • HTTPS, obviously: TLS 1.2 minimum, 1.3 preferred. HSTS with a long max-age. No mixed content on your consent screen.
  • Content Security Policy on the consent screen: The consent screen is a high-value phishing target. Lock down your CSP so injected scripts can’t hijack the ?code= redirect.
  • Short-lived everything: Authorization codes expire in 60–120 seconds. Access tokens in 5–15 minutes. Only refresh tokens live long, and they’re hashed at rest.

The OAuth spec is large enough that people treat it like a black box and reach for a hosted service. That’s fine for most cases. But when you need to own the full authorization stack — for compliance, for cost, for control — the building blocks aren’t complicated. They just require precision. Get the token lifecycle right, design scopes as user-visible commitments not internal API labels, and build a consent screen that respects the user’s intelligence. The rest is plumbing.

👁 Views: 202,847 · Unique visitors: 57,406