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.
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 fromA-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:
- Check that
stateequals the value you stored. If not, stop. - Check that
issequals 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:
- Signature. Fetch the keys from
jwks_uri(https://YOUR_ONEID/.well-known/jwks), pick the key whosekidmatches the token header, and verify with RS256. Cache the keys, and re-fetch when a token carries an unknownkid. issequalshttps://YOUR_ONEID/, including the trailing slash.audequals your client ID.expis in the future. ID tokens last 20 minutes by default.nonceequals 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,stateandnoncefor every sign-in. UseS256only. - Check
stateandisson the callback, andnoncein 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.