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_methodSignet 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
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
sessionStorageif 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
stateandcode_verifieragainst the user session keyed bystate, 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 sentresource=...on/oauth/authorize, you may passresource=...here too — but it must be a subset of what you sent at the authorize step (RFC 8707 §2.2). Widening returns400 invalid_target. Omitresourceto 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_uriyou 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
- github.com/go-signet/oauth-cli — Auth Code + PKCE in Go
- github.com/go-signet/cli — Hybrid CLI (Device Flow over SSH, Auth Code locally)