Authorization Code Flow + PKCE

The Authorization Code Flow (RFC 6749) with PKCE (Proof Key for Code Exchange, RFC 7636) is the recommended OAuth 2.0 flow for web applications, single-page apps (SPAs), and mobile apps.

When to Use This Flow

Use Authorization Code + PKCE when:

  • You are building a server-rendered web app (confidential client — has a backend that can hold client_secret)
  • You are building a single-page app (public client — no secret)
  • You are building a mobile or desktop app (public client)
  • You need users to see a consent screen before granting access

Client Types

Type Credentials Typical examples Must use PKCE?
confidential client_id + client_secret Rails / Django / Node backend Recommended
public client_id only (no secret) React SPA, iOS/Android, Electron Yes — always

PKCE (S256) is always safe to use and is the only code_challenge_method Signet accepts. Confidential clients should also include it — defence-in-depth.

Client ID Metadata Documents (CIMD) — registration-free MCP clients

When the operator sets CIMD_ENABLED=true (default off), an MCP client may present a self-hosted HTTPS URL as its client_id (e.g. https://app.example.com/client.json). Signet fetches the JSON metadata document at that URL during authorization and registers the client automatically — no admin pre-registration and no dynamic-registration call. The document’s client_id must exactly equal the URL it is served from, it must list your redirect_uris, and token_endpoint_auth_method must be none (CIMD clients are always public, so S256 PKCE is mandatory). Such clients run the authorization-code flow exactly as described below; their RFC 8707 resource values are checked against the server-wide CIMD_ALLOWED_RESOURCES allowlist, and the consent page shows the client’s domain plus an unverified-client notice. This follows the MCP 2026-07-28 authorization spec; Dynamic Client Registration (RFC 7591) remains available as a legacy fallback. See MCP Client Metadata (CIMD) for the complete hosting, server implementation, and CIDR/SSRF guide.

How It Works

sequenceDiagram participant App participant Signet participant Browser App->>App: (1) Generate code_verifier + code_challenge (PKCE) App->>Browser: (2) Redirect to /oauth/authorize<br/>?code_challenge=...&client_id=...&state=...&nonce=... Browser->>Signet: GET /oauth/authorize Signet->>Browser: Show login page (if not logged in) Browser->>Signet: Submit credentials Signet->>Browser: Show consent screen (scopes) Browser->>Signet: User approves Signet->>Browser: (3) Redirect to redirect_uri?code=AUTH_CODE&state=...&iss=... Browser->>App: Callback with AUTH_CODE App->>Signet: (4) POST /oauth/token (code + code_verifier) Signet-->>App: (5) access_token + refresh_token [+ id_token]

Step 1: Generate PKCE Parameters

Generate a cryptographically random code_verifier (43–128 chars) and derive the code_challenge:

Go

import (
    "crypto/rand"
    "crypto/sha256"
    "encoding/base64"
)

buf := make([]byte, 32)
_, _ = rand.Read(buf)
codeVerifier := base64.RawURLEncoding.EncodeToString(buf)

h := sha256.Sum256([]byte(codeVerifier))
codeChallenge := base64.RawURLEncoding.EncodeToString(h[:])

Python

import hashlib, base64, secrets

code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode()
digest = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode()

JavaScript (Node / Browser via crypto.subtle)

// Node 16+:
const codeVerifier = crypto.randomBytes(32).toString("base64url");
const codeChallenge = crypto
  .createHash("sha256")
  .update(codeVerifier)
  .digest("base64url");

Store the code_verifier for use in Step 4:

  • Confidential clients: server-side session.
  • SPAs: prefer an in-memory variable. Only fall back to sessionStorage if you must survive a reload — note that any browser storage is reachable from XSS, and the real mitigation is a Backend-For-Frontend (BFF) pattern.

Step 2: Redirect to Authorization Endpoint

GET /oauth/authorize
  ?client_id=YOUR_CLIENT_ID
  &redirect_uri=https://yourapp.example/callback
  &response_type=code
  &scope=openid profile email offline_access
  &state=RANDOM_STATE
  &nonce=RANDOM_NONCE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
Parameter Required Notes
client_id yes From the admin
redirect_uri yes Exact string match against a registered URI. Opt-in exception (RFC 8252 §7.3): when the operator sets LOOPBACK_REDIRECT_ANY_PORT_ENABLED=true, a registered plain-http loopback URI without a port — http://localhost/callback, http://127.0.0.1/callback, http://[::1]/callback — matches any port. Hostname, escaped path, and raw query stay exact (localhost never matches 127.0.0.1)
response_type yes Must be code (the only type Signet supports)
scope recommended Space-separated; include openid for an ID token
state yes (CSRF) Random value — validate on callback
nonce OIDC Required when scope contains openid; ends up in id_token for replay protection
code_challenge PKCE Derived as above
code_challenge_method PKCE Must be S256 (plain is rejected)
resource optional RFC 8707 Resource Indicator — absolute http(s) URI, no fragment, ≤ 1024 chars; repeat for multiple resources (max 10). When supplied, the issued access token’s aud is bound to these values — but each must be on your client’s allowed-resources allowlist (deny-all by default). Malformed or not-allowlisted → invalid_target

State & nonce: generate independently, random, and long enough (≥ 16 bytes base64url). Persist state and code_verifier against the user session keyed by state, so the callback can look them up.

The user will be prompted to log in (if not already) and then see a consent screen listing requested scopes. When resource is supplied, the consent screen separately lists the audience target(s) the token will be valid for.

When the server remembers consent (CONSENT_REMEMBER=true, the default), it is remembered per resource set: approving the same app for a different resource combination stores an independent grant instead of overwriting the earlier one, and each grant is listed and revocable on its own under Account → Authorizations. The consent screen re-appears whenever the requested resource set matches no stored grant — including a request with no resource after a resource-bound approval (or vice versa) — so the audience the user approved is never silently changed. (Earlier releases kept a single grant per app; consenting to a new resource set used to overwrite the old one.)

Step 3: Handle the Callback

https://yourapp.example/callback?code=AUTH_CODE&state=RANDOM_STATE&iss=https://your-signet

Before using code, verify state matches what you sent. If it doesn’t, abort.

Every callback also carries an iss parameter (RFC 9207) identifying the issuing server — its value always equals the issuer field of the discovery document. After validating state, every client must require iss and compare it with the issuer recorded for this authorization request using an exact string comparison; do not perform URL normalization. Reject a missing or mismatched value before processing the response or redeeming any code. This also applies to clients configured for only one authorization server because Signet advertises authorization_response_iss_parameter_supported: true.

If the user denied consent, Signet redirects with an OAuth error instead (the iss parameter is present on error redirects too):

https://yourapp.example/callback?error=access_denied&error_description=...&state=RANDOM_STATE&iss=https://your-signet

See Errors for the full list.

Step 4: Exchange Code for Tokens

The /oauth/token endpoint accepts only application/x-www-form-urlencoded — not JSON.

Public client (PKCE only):

curl -X POST https://your-signet/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=AUTH_CODE" \
  -d "redirect_uri=https://yourapp.example/callback" \
  -d "client_id=YOUR_CLIENT_ID" \
  -d "code_verifier=CODE_VERIFIER"

Confidential client (HTTP Basic — recommended):

curl -X POST https://your-signet/oauth/token \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "grant_type=authorization_code" \
  -d "code=AUTH_CODE" \
  -d "redirect_uri=https://yourapp.example/callback" \
  -d "code_verifier=CODE_VERIFIER"

Or put the credentials in the form body (client_id=...&client_secret=...). HTTP Basic is preferred per RFC 6749 §2.3.1.

Narrowing resource: if you sent resource=... on /oauth/authorize, you may pass resource=... here too — but it must be a subset of what you sent at the authorize step (RFC 8707 §2.2). Widening returns 400 invalid_target. Omit resource to receive a token bound to the full granted set.

Response:

{
  "access_token": "eyJhbG...",
  "refresh_token": "def502...",
  "id_token": "eyJhbG...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email offline_access"
}

id_token is only present when openid was in the requested scope. See OpenID Connect for its claims and how to verify it.

The same redirect_uri you sent in Step 2 must be sent again here — Signet compares them exactly.

Step 5: Refresh the Access Token

When the access token nears expiry, trade the refresh token for a new pair. See Tokens & Revocation §Refreshing Tokens — pay special attention to the rotation-mode reuse-detection gotcha, which revokes the entire token family if an old refresh token is used twice.

Step 6: Sign Out

On logout, revoke the refresh token (not just the local session) — see Tokens & Revocation §Sign Out. Dropping the cookie alone leaves a stolen token valid until it expires.

Managing User Authorizations

Users can review and revoke per-app access at Account → Authorized Apps in the Signet UI. Expect to see a user return to your app with an expired/revoked token — handle invalid_grant and restart the flow.

Security Checklist

Requirement Details
Always validate state Prevents CSRF on the callback
Always validate nonce For OIDC — compare the id_token nonce claim to what you sent
Always use PKCE (S256) Defence-in-depth even for confidential clients
Use HTTPS everywhere Tokens and codes must never cross the wire in cleartext
Exact redirect URI Signet does exact-string match — watch for trailing slashes. Opt-in loopback exception: set LOOPBACK_REDIRECT_ANY_PORT_ENABLED=true, register http://localhost/callback (no port), and any runtime port works (RFC 8252)
Revoke on logout Call /oauth/revoke with the refresh token
Short-lived access tokens Honor expires_in; refresh proactively (e.g. 30s before expiry)
Validate id_token Signature, iss, aud=client_id, exp, nonce — see OpenID Connect
Validate access-token aud At your resource server, require aud matches your resource identifier (RFC 8707) — see JWT Verification
SPA token storage Prefer BFF. Otherwise: access token in memory only; refresh token in HttpOnly; Secure; SameSite=Lax cookie. Never localStorage.
Native / CLI storage OS keychain / Credential Manager / Secret Service — see Device Flow §Storing Tokens Locally

Example Clients