Authorization code flow with PKCE

Sign users in with OneiD using the authorization code flow with PKCE, from the authorisation request to a validated ID token.

View as Markdown

Every application that signs users in with OneiD uses the authorization code flow with PKCE. This guide shows each request and response, so you can check what your library does or build the flow yourself. It applies to public clients (browser, mobile, desktop) and confidential clients (server-side) alike; only the token request differs.

Before you start

You need:

  • a registered client with the authorization code grant (see Register an application)
  • your client ID, and for a confidential client the client secret
  • a redirect URI registered for the client, exactly as your application will send it
  • your OneiD address, https://YOUR_ONEID

Your library reads the endpoints from https://YOUR_ONEID/.well-known/openid-configuration. The examples below use the paths directly.

How it works

1. Create code_verifier, state and nonce. Derive code_challenge.
2. Redirect the browser to /connect/authorize.
3. The user signs in at OneiD (and consents, if asked).
4. OneiD redirects back to your redirect_uri with code, state and iss.
5. Check state and iss. Exchange code + code_verifier at /connect/token.
6. Validate the ID token. Start your application session.
7. Call your API with the access token.

Step 1: Create the PKCE values, state and nonce

For every sign-in, create three random values and keep them where your callback can find them, such as the server session or, in a browser application, session storage:

  • code_verifier: 43 to 128 characters from A-Z, a-z, 0-9, -, ., _, ~. Keep it secret until step 5.
  • state: protects the callback against cross-site request forgery.
  • nonce: ties the ID token to this sign-in.

Then derive the code_challenge: the SHA-256 hash of the verifier, Base64url-encoded without padding. Always use S256.

function base64url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

const random = () => base64url(crypto.getRandomValues(new Uint8Array(32)));

const codeVerifier = random();
const state = random();
const nonce = random();
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(codeVerifier));
const codeChallenge = base64url(new Uint8Array(digest));

The same in a shell, for testing:

CODE_VERIFIER=$(openssl rand -base64 64 | tr -d '\n=' | tr '+/' '-_' | cut -c1-64)
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr -d '\n=' | tr '+/' '-_')

Step 2: Send the authorisation request

Redirect the user’s browser to the authorize endpoint. Line breaks are for reading only.

GET /connect/authorize
  ?client_id=YOUR_CLIENT_ID
  &response_type=code
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
  &scope=openid%20profile%20email%20offline_access
  &state=hX3n9qT2vYw8LkP0
  &nonce=Qm4rT8zWc1JpVb6s
  &code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
  &code_challenge_method=S256 HTTP/1.1
Host: YOUR_ONEID
Parameter Required Value
client_id Yes Your client ID.
response_type Yes code. No other response type is accepted.
redirect_uri Yes A registered redirect URI, exactly.
scope Yes Space-separated. Include openid to receive an ID token. Add offline_access for a refresh token.
code_challenge Yes The value from step 1.
code_challenge_method Yes S256.
state Recommended The value from step 1.
nonce Recommended The value from step 1.
response_mode No query (default) or form_post.
prompt, max_age, id_token_hint No See Sessions, prompt and max_age.
claims No Requests individual claims for the id_token or userinfo. Only claims within allowed and consented scopes are released.

OneiD ignores login_hint, ui_locales, display and acr_values. It refuses request and request_uri with request_not_supported and request_uri_not_supported.

If the client_id is unknown or the redirect_uri is not registered, OneiD does not redirect back. It shows its own error page with invalid_request.

Step 3: The user signs in

OneiD shows its sign-in page if the user has no OneiD session, and asks for an authenticator code if MFA applies. If the client asks for consent, OneiD shows the consent page. Your application is not involved in this step.

Step 4: Receive the callback

OneiD redirects the browser to your redirect URI:

HTTP/1.1 302 Found
Location: https://app.example.com/callback?code=EXAMPLE_AUTHORIZATION_CODE&state=hX3n9qT2vYw8LkP0&iss=https%3A%2F%2FYOUR_ONEID%2F

Before you use the code:

  1. Check that state equals the value you stored. If not, stop.
  2. Check that iss equals the issuer from discovery, https://YOUR_ONEID/. This shows the response came from the OneiD you called.

If sign-in did not succeed, the callback carries an error instead of a code:

GET /callback?error=access_denied&state=hX3n9qT2vYw8LkP0 HTTP/1.1
Host: app.example.com

access_denied means the user refused consent. For the other codes, see Errors and troubleshooting.

The code is valid for 5 minutes and works once.

Step 5: Exchange the code for tokens

Public client

A public client sends its client_id in the body and no secret. PKCE proves that the same application started the sign-in.

POST /connect/token HTTP/1.1
Host: YOUR_ONEID
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=EXAMPLE_AUTHORIZATION_CODE
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&client_id=YOUR_CLIENT_ID

Confidential client

A confidential client authenticates with client_secret_basic: the client ID and secret, joined by a colon and Base64-encoded, in the Authorization header.

POST /connect/token HTTP/1.1
Host: YOUR_ONEID
Authorization: Basic WU9VUl9DTElFTlRfSUQ6WU9VUl9DTElFTlRfU0VDUkVU
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&code=EXAMPLE_AUTHORIZATION_CODE
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

The same with curl:

curl -s https://YOUR_ONEID/connect/token \
  -u "YOUR_CLIENT_ID:YOUR_CLIENT_SECRET" \
  -d grant_type=authorization_code \
  -d code=EXAMPLE_AUTHORIZATION_CODE \
  --data-urlencode redirect_uri=https://app.example.com/callback \
  -d code_verifier="$CODE_VERIFIER"

OneiD also accepts client_secret_post (the secret as client_secret in the body). Prefer client_secret_basic.

The token response

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVYQU1QTEVfS0lEIn0.EXAMPLE_ACCESS_TOKEN_PAYLOAD.EXAMPLE_SIGNATURE",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid profile email offline_access",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IkVYQU1QTEVfS0lEIn0.EXAMPLE_ID_TOKEN_PAYLOAD.EXAMPLE_SIGNATURE",
  "refresh_token": "EXAMPLE_OPAQUE_REFRESH_TOKEN_7Hq2Lm9Xv4Rk"
}

refresh_token is present only when you requested offline_access and the client has the refresh token grant. Treat it as opaque. See Refresh tokens.

If the exchange fails, OneiD returns a JSON error response with an error field. invalid_grant means the code expired, was already used, or the code_verifier does not match. Do not retry with the same code; start a new sign-in.

Step 6: Validate the ID token

The ID token is a JWT signed with RS256. A decoded payload looks like this:

{
  "iss": "https://YOUR_ONEID/",
  "sub": "8d0c6a3e-2f4b-4c1e-9a77-1b2c3d4e5f60",
  "aud": "YOUR_CLIENT_ID",
  "exp": 1767226800,
  "iat": 1767225600,
  "nonce": "Qm4rT8zWc1JpVb6s",
  "auth_time": 1767225590,
  "amr": ["pwd", "mfa"],
  "acr": "urn:oltin:ac:mfa",
  "name": "Alex Example",
  "preferred_username": "alex",
  "email": "alex@example.com",
  "email_verified": true,
  "idp": "local"
}

Your library should check, and you should confirm that it does:

  1. Signature. Fetch the keys from jwks_uri (https://YOUR_ONEID/.well-known/jwks), pick the key whose kid matches the token header, and verify with RS256. Cache the keys, and re-fetch when a token carries an unknown kid.
  2. iss equals https://YOUR_ONEID/, including the trailing slash.
  3. aud equals your client ID.
  4. exp is in the future. ID tokens last 20 minutes by default.
  5. nonce equals the value you stored in step 1.

Then use iss and sub together as the user’s key in your application. Never key users by email. Ignore claims you do not recognise, including private claims whose names start with oi_.

The ID token is for your application only. Send the access token, not the ID token, to APIs.

Step 7: Use the access token

Send the access token as a bearer token:

GET /orders HTTP/1.1
Host: api.example.com
Authorization: Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6IkVYQU1QTEVfS0lEIn0.EXAMPLE_ACCESS_TOKEN_PAYLOAD.EXAMPLE_SIGNATURE

To read the user’s claims from OneiD, call the userinfo endpoint with the same token:

curl -s https://YOUR_ONEID/connect/userinfo \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Using form_post

With response_mode=form_post, OneiD returns the code in an HTML form that the browser posts to your redirect URI, instead of in the query string. The code then does not appear in the browser history or in server logs of the URL.

POST /callback HTTP/1.1
Host: app.example.com
Content-Type: application/x-www-form-urlencoded

code=EXAMPLE_AUTHORIZATION_CODE&state=hX3n9qT2vYw8LkP0&iss=https%3A%2F%2FYOUR_ONEID%2F

Your callback must accept POST. The post comes from OneiD’s origin, so a cookie with SameSite=Lax or Strict is not sent with it. If your application keeps state or the verifier in a cookie, that cookie needs SameSite=None; Secure.

Use query or form_post. Discovery also lists fragment, but it is not recommended.

Security notes

  • Use a new code_verifier, state and nonce for every sign-in. Use S256 only.
  • Check state and iss on the callback, and nonce in the ID token.
  • Do not reload the callback page. The code works once; a second exchange returns invalid_grant.
  • Keep the client secret on the server. A browser, mobile or desktop application is a public client and has no secret.
  • Run your application on https. Some frameworks, such as ASP.NET Core, refuse the callback with “Correlation failed” when the application runs on plain http.
  • Do not build the authorisation request from user input. Use a fixed, registered redirect URI.

Learn more