Skip to content

Authorization Code + PKCE ​

The flow every web app, mobile app, SPA, and desktop app uses to log a human in. The RP sends the browser to the OP, the user signs in and consents there, and the RP trades the one-time code that comes back for tokens at /token.

PKCE (Proof Key for Code Exchange, RFC 7636) ties that code to the client that started the flow. This library requires it for public and native clients, and for every client under the OAuth21 (the default when no profile is declared), FAPI2Baseline, and FAPI2MessageSigning profiles. A public client that omits code_challenge gets invalid_request at /auth, because it cannot authenticate at /token and the verifier is its only proof of identity. Confidential clients may skip PKCE only when the deployment declares op.WithProfile(profile.OIDCCore) for legacy relying parties; new integrations should send S256 everywhere.

Specs referenced on this page
New to the vocabulary?
  • Authorization code — a one-time, opaque string the OP hands to the RP via a browser redirect. The RP swaps it at /token for the actual tokens.
  • PKCE ("pixie") — a small extra dance with code_verifier / code_challenge that proves "the client redeeming this code is the same one that started the flow." Stops a malicious app from stealing a redirected code. Walked through in detail below.
  • state — a random opaque value the RP sends with the authorize request and re-checks on the callback; CSRF defence for the redirect.
  • nonce — a random opaque value bound into the ID Token; replay defence at the RP.

The full sequence ​

The browser carries the request and the code
The first half of Authorization Code with PKCE. The Relying Party builds a PKCE pair and sends the user to the OpenID Provider through the browser; the provider validates the request, authenticates the user and collects consent, then redirects back to the Relying Party with a code and state.authorization requestauthenticate and consentRelying Partyyour web appUserbrowserOpenID Providergo-oidc-provideropen the appbuild the PKCE paircode_challenge =S256(code_verifier)302 → /authcode_challenge · state · nonceGET /authvalidate the requestredirect_uri exact matchclient_id · S256200 login pagePOST credentialsauthenticatepassword · passkey · MFA200 consent pagePOST consent302 → redirect_uricode · stateGET /callback?code&state
The OP validates redirect_uri, client_id and the S256 challenge before it shows any login page, so a malformed request never reaches the user. Only the challenge leaves the RP here; the verifier stays behind until the token request.
PKCE is checked where the code is redeemed
The second half of Authorization Code with PKCE. The Relying Party verifies state, redeems the code at the token endpoint together with the PKCE verifier, the OpenID Provider checks the verifier against the earlier challenge and returns tokens, and the Relying Party then calls the Resource Server with the access token.token exchangeresource accessRelying Partyyour web appOpenID Providergo-oidc-providerResource Serveryour APIverify statemust match the stored valuePOST /token · client authcode · code_verifierverify PKCES256(code_verifier)== code_challenge200 OKaccess_token · id_tokenverify the ID tokeniss · aud · exp · noncethen set the session cookieGET /api/meAuthorization: Bearer <access_token>/introspectoptional, or verify JWT locally200 OK · { data }
The verifier appears only in the /token request, so a code intercepted in the redirect is useless without it: the OP accepts the code only from whoever knows the verifier behind the challenge it saw earlier. The RP still checks the ID token nonce before it trusts the login.

Parameter glossary ​

Pages on this site write the authorization endpoint as /auth, the library default. With default routing the OP mounts under /oidc, so the authorization endpoint is served at /oidc/auth and the token endpoint at /oidc/token.

ParameterSent atPurpose
response_type=code/authAsks for the authorization-code grant.
client_id/auth, /tokenIdentifies the registered RP.
redirect_uri/auth (and echoed at /token)Where the OP sends the user back. Exact match against the registered list.
scope/authPermissions requested. Must include openid for OIDC.
state/authRandom opaque value the RP echoes on callback. CSRF defense for the redirect.
nonce/authRandom value bound into the ID Token's nonce claim. Replay defense.
code_challenge/authBASE64URL(SHA256(code_verifier)).
code_challenge_method/authS256 (the only one this library accepts).
code/auth responseSingle-use. Valid for 60 seconds; RFC 6749 §4.1.2 recommends a 10-minute maximum.
code_verifier/tokenThe pre-image of code_challenge. The OP recomputes the SHA-256.
grant_type=authorization_code/tokenSelects this grant.
Client auth/tokenOne of client_secret_basic, client_secret_post, private_key_jwt, or none (PKCE-only). mTLS sender constraint is separate from token-endpoint client authentication.
state vs nonce — what's the difference?

Both are random opaque values, both defend against replay-style attacks, but they protect different legs of the flow:

  • state travels on the front channel (browser query string). The RP stashes it in the user's session before redirecting and re-checks it on the callback. It defends the redirect against CSRF — an attacker can't forge a callback to your /callback and have your app accept it.
  • nonce travels in the ID Token claim. The RP stashes it in the user's session before redirecting and re-checks it after token exchange. It defends the ID Token against replay — an attacker can't reuse a stolen ID Token at a different RP, or at the same RP for a different login attempt.

Use both when your RP validates an ID Token. OIDC makes nonce mandatory for response types that include an ID Token; this library accepts response_type=code only, so hybrid and implicit response types are rejected before the authorization flow starts.

code_verifier / code_challenge / S256 — what's that?

code_verifier is a high-entropy random string the RP generates and keeps to itself. RFC 7636 §4.1 mandates 43-128 URL-safe characters.

code_challenge is what the RP sends to the OP at /auth. With code_challenge_method=S256, it's BASE64URL(SHA-256(code_verifier)) — a one-way hash. The OP can't reverse it; only the RP can prove ownership later by sending the verifier itself.

S256 is the SHA-256-based transform; it's the only code_challenge_method this library accepts. The legacy plain method (where challenge equals verifier) provides no protection against an attacker who reads the URL, so RFC 9700 forbids it for new deployments.

redirect_uri — strict exact-match, and why

The redirect_uri on /auth is checked byte-for-byte against the client's registered list — no tail-slash normalisation, no path-prefix matches, no wildcards. That strictness is on purpose: open-redirect bugs and "any subpath of https://app.example.com/" patterns are a well-trodden way to leak codes to attacker-controlled URLs. RFC 9700 §2.1 requires exact match, and this library enforces it. At /token, the RP must repeat the same redirect_uri it sent on /auth; a mismatch returns invalid_grant.

response_type=code — what's that?

response_type=code asks for the authorization code flow — the OP returns a short-lived code on the redirect, and the RP swaps it at /token for the actual tokens. The alternatives (token, id_token token, code id_token, etc.) are legacy hybrid / implicit flows that OAuth 2.0 BCP (RFC 9700) discourages. This library supports code only and rejects every other response type.

PAR — what's that, and when do I need it?

PAR (Pushed Authorization Requests, RFC 9126) lets the RP POST the authorize parameters to a server-side /par endpoint first, get back a short-lived request_uri, then redirect the browser with just ?client_id=...&request_uri=.... The benefits:

  • The full request never appears in browser history, server logs, or referrer headers.
  • Tampering at the user-agent boundary is moot — only the request_uri is exposed there.
  • Required by the FAPI 2.0 Security Profile. Also recommended for authorization-code clients outside FAPI.

Wire it via op.WithFeature(feature.PAR) and the discovery document advertises pushed_authorization_request_endpoint.

What PKCE prevents ​

Walk-through: the attack PKCE blocks

Without PKCE, a malicious app on the same device that controls a URI-handler for myapp:// can intercept the authorization-code redirect:

  1. User logs in on the legit RP. OP issues code=abc to myapp://callback.
  2. Malicious app intercepts the redirect (race condition or universal-link spoof) and reads code=abc.
  3. Malicious app posts code=abc to /token and gets tokens.

PKCE binds the code to a secret only the legitimate RP knows:

  1. The legit RP generates a random code_verifier and sends only SHA256(code_verifier) (the code_challenge) to /auth.
  2. The OP stores code_challenge alongside the issued code.
  3. At /token, the OP requires code_verifier and recomputes the SHA-256.
  4. The malicious app saw the code but never saw the verifier — its /token call fails.

This works even when the RP can't store a client secret (SPA / native).

How this library enforces it ​

BehaviourWhere
code_challenge_method=plain is rejected — only S256 accepted.internal/pkce
An authorization request without code_challenge is rejected with invalid_request for public and native clients, and for every client under OAuth21 (the default), FAPI2Baseline, and FAPI2MessageSigning; only OIDCCore lets a confidential client omit it.internal/authorize
code_verifier length and char-set are validated against RFC 7636 §4.1.internal/pkce
A mismatch returns RFC 6749 §5.2 invalid_grant at /token (not /auth).internal/tokenendpoint/authcode.go
A public client's code that was issued without a challenge is refused at /token with invalid_grant, even if the request carries a verifier.internal/tokenendpoint/authcode_enforce.go
A code that was issued with a challenge refuses a request without a matching verifier, and a code issued without one refuses any verifier.internal/grants/authcode
A wrong client, a different redirect_uri, or a failed PKCE check rejects the request without spending the code. Only a second redemption of an already-used code counts as a replay and revokes the grant's tokens (RFC 6749 §4.1.2).internal/grants/authcode

Common errors and what they mean ​

Wire errorCauseWhere to look
invalid_request (at /auth)code_challenge missing for a client that needs PKCESend code_challenge with code_challenge_method=S256
invalid_request code_challenge_methodClient sent plain, or no methodSend S256
unsupported_response_typeresponse_type is not codeUse response_type=code
invalid_request_uriPAR request_uri unknown, expired, or already used to issue a codePush a new PAR request
invalid_grant (at /token)code_verifier doesn't match, or the code is unknown, used, or expiredDon't reuse codes; restart the flow
invalid_grant (at /token)The redirect_uri at /token differs from /authThey must be byte-identical

Run the flow yourself ​

examples/01-minimal runs this exact flow in one process: an OP with a public client plus a small RP that generates state, nonce, and the PKCE pair, then verifies the ID Token. Start there, sign in as demo / demo, and read the redirect URLs in the browser. examples/03-fapi2 is the same flow hardened with PAR, private_key_jwt client authentication, and a DPoP-bound access token under FAPI2Baseline. The OFCS results include the FAPI 2.0 Security Profile Final and Message Signing Final plans.