# 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.

Source: https://oltinid.com/docs/guides/authorization-code-pkce/ · Section: Guides · All OneiD documentation: https://oltinid.com/llms.txt

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](https://oltinid.com/docs/get-started/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

```text
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`.

```js
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:

```bash
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.

```http
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](https://oltinid.com/docs/guides/sessions-and-reauthentication/). |
| `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
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:

```http
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](https://oltinid.com/docs/reference/errors/).

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.

```http
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.

```http
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:

```bash
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

```json
{
  "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](https://oltinid.com/docs/guides/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:

```json
{
  "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:

```http
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:

```bash
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.

```http
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

- [Refresh tokens](https://oltinid.com/docs/guides/refresh-tokens/)
- [Tokens](https://oltinid.com/docs/reference/tokens/)
- [Errors and troubleshooting](https://oltinid.com/docs/reference/errors/)
