Skip to content
TDACorpIDENTITYSign in

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#

  1. Your server prepares a transaction. It makes a random PKCE verifier, derives the challenge from it, and makes a random state and nonce. It stores all of them, tied to this one browser, until the person comes back.
  2. Your server pushes the request. It sends the parameters directly to identity over a back channel, authenticated as your client, and gets a request_uri back. See Pushed authorization requests.
  3. Your server redirects the browser to the authorization endpoint with only client_id and request_uri.
  4. 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_enforcement flag, 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.
  5. Identity redirects the browser back to your registered redirect address with code, state and iss.
  6. Your server checks the response, then sends the code and the PKCE verifier to the token endpoint, authenticated as your client.
  7. Identity returns tokens. Your server verifies the ID token and creates its own session.

PKCE and the values you generate#

ValueRule
code_verifierA 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_challengeThe base64url encoding, without padding, of the SHA-256 hash of the verifier. Always 43 characters.
code_challenge_methodS256. It is required, not defaulted, and plain is refused.
stateA random value of at least 16 and at most 512 printable ASCII characters. It ties the response to your transaction.
nonceA 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.

ParameterValue
client_idYour client id.
response_typecode. The only value.
redirect_uriOne of your registered redirect addresses, exactly.
scopeA space-separated list that includes openid and only scopes your client is registered for. See Scopes and consent.
state, nonce, code_challenge, code_challenge_methodAs above.
response_modequery or form_post. A web client defaults to form_post, and every other client to query. The SDK asks for query.
promptA space-separated list. See below.
authorization_detailsAccepted for two registered types only. Not covered here.
max_ageThe 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_valuesaal1, 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_tenantThe 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.
resourceThe API the access token is for. See Resource indicators.
login_hintPre-fills the identifier field of the sign-in page. It never skips sign-in and never chooses an account.
ui_localesA 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#

ValueBehaviour
noneNever 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.
loginAlways sign in again, even with a live session.
consentAlways show the consent screen, even if consent was already given. A first-party client never shows it.
createAlways shows the sign-in step, as login does.
select_accountTreated 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:

OutcomeParameters
Successcode, state, iss
Refusal after sign-inerror, 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:

  1. The stored transaction exists, has not expired and belongs to this issuer and this client.
  2. None of code, state, iss or error appears twice.
  3. iss is present and equals the issuer you started at, byte for byte.
  4. state equals the one you stored, compared in constant time.
  5. Only then: if error is present, report it. Otherwise read code.

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_uri is 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:

FieldMeaning
access_tokenA signed token, addressed to your client or to the resource you asked for. At most 900 seconds.
token_typeDPoP when the token is bound to your key, otherwise Bearer.
expires_inSeconds until the access token expires.
refresh_tokenOnly when your client may refresh and you asked for offline_access. See Refresh tokens.
id_tokenOnly when you asked for openid, which is always.
scopeWhat 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:

  • typ in the header is JWT, and the signature verifies against the key set for the kid, with an algorithm you allow.
  • iss is the issuer, aud is your client id, and exp and iat are sensible.
  • nonce equals the one you stored.
  • If you sent max_age, auth_time is recent enough. If you sent acr_values, acr is 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#

Verified against commit ca46b57