Authorization code with PKCE
The sign-in flow end to end, every parameter identity accepts, what comes back, and what your code must check.
On this page
This is the flow that signs a person in. Your server sends identity a request that says who you are and what you want, the person proves who they are in the browser, and identity gives your server a short-lived code that it trades for tokens. The code never carries a token, and it is useless without a secret that only your server knows.
The SDK performs every step on this page. Read it to know what the SDK is doing, to review it, or to build the flow in another language.
The steps#
- Your server prepares a transaction. It makes a random PKCE verifier, derives the challenge from it, and makes a random
stateandnonce. It stores all of them, tied to this one browser, until the person comes back. - Your server pushes the request. It sends the parameters directly to identity over a back channel, authenticated as your client, and gets a
request_uriback. See Pushed authorization requests. - Your server redirects the browser to the authorization endpoint with only
client_idandrequest_uri. - Identity signs the person in. It shows its own sign-in page if needed, may ask for a second factor, may send the person through their organization's single sign-on connection (the
sso_enforcementflag, default OFF), may ask them to create a workspace when your client needs one and they have none, and shows a consent screen to a third-party client the first time. - Identity redirects the browser back to your registered redirect address with
code,stateandiss. - Your server checks the response, then sends the code and the PKCE verifier to the token endpoint, authenticated as your client.
- Identity returns tokens. Your server verifies the ID token and creates its own session.
PKCE and the values you generate#
| Value | Rule |
|---|---|
code_verifier | A random string of 43 to 128 characters from letters, digits, -, ., _ and ~. A 32 byte random value in base64url is 43 characters. Keep it on the server. |
code_challenge | The base64url encoding, without padding, of the SHA-256 hash of the verifier. Always 43 characters. |
code_challenge_method | S256. It is required, not defaulted, and plain is refused. |
state | A random value of at least 16 and at most 512 printable ASCII characters. It ties the response to your transaction. |
nonce | A random value of printable ASCII, at most 512 characters. It is required, and it comes back inside the ID token. |
The parameters you can send#
All of these go in the pushed request, not in the browser redirect. The authorization endpoint accepts only client_id and request_uri, and refuses anything else.
| Parameter | Value |
|---|---|
client_id | Your client id. |
response_type | code. The only value. |
redirect_uri | One of your registered redirect addresses, exactly. |
scope | A space-separated list that includes openid and only scopes your client is registered for. See Scopes and consent. |
state, nonce, code_challenge, code_challenge_method | As above. |
response_mode | query or form_post. A web client defaults to form_post, and every other client to query. The SDK asks for query. |
prompt | A space-separated list. See below. |
authorization_details | Accepted for two registered types only. Not covered here. |
max_age | The longest age in seconds of the person's last sign-in that you will accept. An older sign-in is replaced by a new one; with acr_values of aal2 or more, by a fresh second factor on the live session. |
acr_values | aal1, aal2 or aal3. The assurance level you need. A lower session is asked for a further step first. To answer a resource server's RFC 9470 insufficient_user_authentication challenge, the SDK's rp.stepUpUrl(challenge, tenant) builds this request with the challenge's max_age and acr_values. |
tdaid_tenant | The canonical lower-case identifier of an organization to sign in to. The person must be an active member and your client's app must be installed there. |
resource | The API the access token is for. See Resource indicators. |
login_hint | Pre-fills the identifier field of the sign-in page. It never skips sign-in and never chooses an account. |
ui_locales | A space-separated list of language tags, best first, for the language of the sign-in pages (OpenID Connect Core section 3.1.2.1). Identity has English, French and Spanish, and French and Spanish are served only where switched on: the first tag it has a language for wins, a region is ignored (fr-CA shows French), and a person's own saved language comes before it. At most eight tags. |
Identity refuses a parameter it does not list, such as id_token_hint, display or request, and refuses a parameter that appears twice, except resource, which may repeat up to eight times. This is deliberate: a parameter that is silently ignored lets you believe a security property holds when it does not.
prompt#
| Value | Behaviour |
|---|---|
none | Never show a page. If the person is not signed in, or consent is missing, the request fails with login_required or consent_required. It cannot be combined with another value. |
login | Always sign in again, even with a live session. |
consent | Always show the consent screen, even if consent was already given. A first-party client never shows it. |
create | Always shows the sign-in step, as login does. |
select_account | Treated as login, because there is no account chooser. |
Any other value is ignored.
What comes back#
Identity redirects to your redirect_uri, by query string or by a form post, with:
| Outcome | Parameters |
|---|---|
| Success | code, state, iss |
| Refusal after sign-in | error, error_description, state, iss |
The errors that arrive here are access_denied (the person said no, or the account has no membership in the organization you asked for, or your client's app is not installed there: identity gives the same answer for all three), login_required and consent_required (both from prompt=none, or from a request that needs a page it may not show), and interaction_required (prompt=none for an organization that requires single sign-on). Errors that mean the request itself is broken cannot be sent to your redirect address, because identity cannot trust it yet: an unknown or already-used request_uri, a suspended client, an address or scope that is no longer registered. A browser gets identity's own error page, and any other caller gets a JSON body with status 400.
Check the response before you use it#
In this order, and send nothing to the token endpoint until every check has passed:
- The stored transaction exists, has not expired and belongs to this issuer and this client.
- None of
code,state,issorerrorappears twice. issis present and equals the issuer you started at, byte for byte.stateequals the one you stored, compared in constant time.- Only then: if
erroris present, report it. Otherwise readcode.
Redeem the code#
POST /tokenContent-Type: application/x-www-form-urlencodedDPoP: <proof>grant_type=authorization_code&client_id=<client id>&code=<code>&redirect_uri=<the same redirect address>&code_verifier=<verifier>&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer&client_assertion=<assertion>The DPoP header is required for a client registered to require it, which is the default, and for every client other than a first-party web client. See DPoP and Client authentication.
A code lives for at most 60 seconds and can be used once. Identity refuses it with invalid_grant when:
- it is unknown, expired or already used,
redirect_uriis not the one the code was issued for,- the verifier does not match the challenge,
- the code was bound to a DPoP key and the request did not prove that same key,
- the session behind the code has ended, or was started through a single sign-on connection that is not linked to the code's organization.
A successful response:
| Field | Meaning |
|---|---|
access_token | A signed token, addressed to your client or to the resource you asked for. At most 900 seconds. |
token_type | DPoP when the token is bound to your key, otherwise Bearer. |
expires_in | Seconds until the access token expires. |
refresh_token | Only when your client may refresh and you asked for offline_access. See Refresh tokens. |
id_token | Only when you asked for openid, which is always. |
scope | What was actually granted. The person can grant fewer scopes than you asked for, so read it. |
Verify the ID token#
The ID token is for your application, not for an API. Check:
typin the header isJWT, and the signature verifies against the key set for thekid, with an algorithm you allow.issis the issuer,audis your client id, andexpandiatare sensible.nonceequals the one you stored.- If you sent
max_age,auth_timeis recent enough. If you sentacr_values,acris at least that level.
Then take sub as the person's stable identifier. For a client registered pairwise, sub is stable per sector, not the same as another sector's. Use sid to tie your session to the identity session so back-channel logout can end it. If verification fails, discard the tokens and revoke any refresh token you were given.
What the person sees#
Sign-in, the second-factor step and the consent screen are identity's own pages, and your application never handles a password or a passkey. They are described from the person's side in Signing in, and the consent screen in Consent screens.
Where to go next#
- Pushed authorization requests for step 2.
- Raw HTTP walkthrough for every request as a curl command.
- Log users in for the SDK route helpers.