Handling the callback
state, nonce, PKCE — and the one rule that catches everybody: absent is not false.
Three checks before you trust anything
stateyou check- Compare against the value you stored server-side for this browser. At least 32 characters of cryptographic randomness, and it must match the authorization URL exactly.
nonceyou check- Compare against the ID token's
nonceclaim after verifying the signature. A token without your nonce is not yours. code_verifierthe server checks- S256 only, and required of every client — a client secret does not stand in for it. Send the verifier that produced the challenge you sent to authorize. A code presented twice is refused, and the tokens it already bought are ended.
Also verify the ID token's iss against the discovery issuer, its aud against your client id, its signature against the JWKS at jwks_uri, and its expiry. claims_supported on the discovery document lists sub, aud, iss, exp, iat, nonce, auth_time, acr, amr, identity_verified, consent_id, first_sign_in and connected_at.
The ID token's own claims
auth_time- Unix seconds. The most recent moment the person proved themselves in their UnifyID session: the sign-in, a passkey re-check, or the confirmation the sign-in to your application asked for.
amr, acr- How they proved it (
fido2,pwd,otp,recovery_code) andurn:unifyid:assurance:standardfor a passkey,…:basicotherwise. identity_verified- Always
true: only a verified person can reach your callback. first_sign_intruewhen this sign-in created the person's connection to your application;falsewhen they were already connected. Greet them accordingly.connected_at- ISO 8601. When the connection was made — the person's first approval of your application, not the latest change to what they share.
{
"iss": "https://api.unifyid.example",
"sub": "sub_7Xk…",
"aud": "vid_live_example",
"nonce": "…",
"auth_time": 1788868800,
"amr": ["fido2"],
"acr": "urn:unifyid:assurance:standard",
"identity_verified": true,
"first_sign_in": false,
"connected_at": "2026-08-01T09:00:00.000Z",
"iat": 1788868810,
"exp": 1788869710
}A private sign-in application's ID token has no auth_time, amr or acr. See What the application receives.
The denial path
Cancel on the consent screen is RFC 6749 access_denied at your redirect URI. So is a sign-in attempt by a person whose identity verification failed, is under review, or whose account is restricted — which is exactly why three of the four test-identity scenarios refuse.
Your callback must therefore handle an error parameter as a first-class outcome, not as an exception. It is not rare and it does not mean anything is broken.
Absent is not false
This is the single most important sentence on the page. Userinfo builds a flat map, adding each claim only if the scope was granted and only if the underlying value exists. There is no null. There is no "declined" marker. There is no per-claim status.
So these two situations produce byte-identical payloads:
- The person did not approve
nationality. - The person approved
nationalityand there is no nationality record on their account.
And "nationality" in person === false tells you nothing about the person’s nationality. It tells you the platform did not release a value.
# The absence of a key is the signal. There is nothing else to look for.
curl -s "$UNIFYID_API/v1/userinfo" \
-H "authorization: Bearer $ACCESS_TOKEN" \
| jq 'has("age_over_18"), .age_over_18'The claim names you will actually see
Userinfo uses OIDC names where OIDC has them, so the key is not always the scope code:
| Scope granted | Key in the userinfo payload |
|---|---|
| always | sub |
| profile | name, given_name, family_name |
| legal_name | legal_name |
| date_of_birth | date_of_birth (YYYY-MM-DD) |
| age | age |
| age_over_18 | age_over_18 |
| nationality | nationality |
| identity_verified | identity_verified |
| identity_assurance_level | identity_assurance_level (level_<n>) |
| phone | phone_number |
| profile_photo | picture (a URL that needs the same access token) |
Contacts are verified contacts only, and one of each: email releases the primary verified address and phone the primary verified number. An unverified address on the account is not a candidate, and there is no scope that releases every address an account has.
What this does not do
- There is no per-claim metadata. No verification timestamp, no method, no retention hint, no confidence. The only verification timestamp anywhere is
document_verified_at, and only for documents, through Identity documents. - Every read is a disclosure. Each userinfo answer is recorded as a disclosure to your application in the same transaction that reads the data. Polling userinfo is not free and not invisible.
- Do not cache a claim as settled fact. The consent behind it can end at any moment, and you will find out at the next 401 — or through a webhook, if you subscribed to one.
Verified against API commit 234dba22 on 2026-10-18. The routes, scopes, checklist, events and limits on these pages are read from that export, not typed.