Login flow
ComplyOnce login is an OAuth 2.0 authorization code flow with PKCE, where the
user approves the login on their phone instead of typing a password. You are a
confidential client: every call that identifies you as a relying party is made by
your server with your client credentials, and your client_secret never reaches a
browser.
Two ways to integrate
What you choose is who renders the page the user waits on while they scan and confirm.
- Host it yourself. Your server creates the session, polls it, and completes it; your page asks your server for progress. This is the flow described through the rest of this page.
- Use the ComplyOnce-hosted login page. Your server still creates the session and still redeems the code, but it asks for a handoff token and redirects the browser to a page ComplyOnce operates, which does the waiting for you. See The hosted login page.
Both are supported permanently, and neither is a migration path away from the other. Hosting it yourself is more work and gives you the whole experience; the hosted page is two fewer moving parts and looks the same for every relying party.
The flow at a glance
- Your server generates a
stateand a PKCE pair, then creates a session on the ComplyOnce backend and gets back asessionId. - Your server renders a waiting page showing the QR code built from that
sessionId. The user scans it with the ComplyOnce app. - Your page asks your server for progress; your server polls ComplyOnce. Once the app has scanned, a pairing code appears on both screens. The user checks they match, then confirms with their PIN.
- Your server sees
CONFIRMED, completes the session, and receives a redirect URL carryingcodeandstate. - Your server verifies
state, then exchangescodefor asubjectId. - Your server establishes its own session.
Every ComplyOnce request here is server-to-server. The browser only ever talks to you, which is why nothing in this flow needs the client secret to leave your server.
Step 1: Create the session
Generate two values per login attempt and store them somewhere tied to this browser — a short-lived signed cookie or your server-side session:
state— an unguessable random value. It comes back unchanged at completion, and comparing it ties the finished login to the browser that started it.code_verifier— the PKCE secret. You send only its SHA-256 hash (code_challenge) now, and the verifier itself later. This proves the token request belongs to the same login that started here.
Then create the session, authenticating with HTTP Basic:
curl -X POST https://<backend-host>/relying-party/v1/session \
-u "$COMPLYONCE_CLIENT_ID:$COMPLYONCE_CLIENT_SECRET" \
-H 'Content-Type: application/json' \
-d '{
"redirect_uri": "https://portal.example.com/auth/callback",
"state": "xr7Kp2mQ9vB4nL1s",
"signature_type": "AUTHENTICATION",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256"
}'
{
"sessionId": "3f2a7c18-9b4e-4d6a-8f11-2c5e7a90b3d4",
"expiresAt": 1753876543
}
| Field | Value |
|---|---|
redirect_uri |
Must exact-match a registered URI. See Registration. |
state |
Your anti-forgery value, echoed back verbatim. Maximum 500 characters. |
signature_type |
AUTHENTICATION. (SIGNING is rejected — see Signing flow.) |
code_challenge |
Base64url-encoded SHA-256 of code_verifier, no padding. 43–128 characters from A–Z a–z 0–9 - . _ ~. |
code_challenge_method |
S256. Nothing else is accepted. |
There is no client_id field — you are identified by the credential you
authenticate with. One optional field, properties, requests identity attributes
or predicates (such as the user's name, or a check like "is at least 18") to be
returned with the login — see
Identity properties.
Keep the sessionId in the same place you kept state and code_verifier: your
server needs it to poll and to complete, and it must not be something the browser
can change.
Node.js
import { createHash, randomBytes } from 'node:crypto'
export function createPkcePair() {
const codeVerifier = randomBytes(32).toString('base64url')
const codeChallenge = createHash('sha256').update(codeVerifier).digest('base64url')
return { codeVerifier, codeChallenge }
}
export function basicAuthorization() {
const credential = `${config.clientId}:${config.clientSecret}`
return `Basic ${Buffer.from(credential, 'utf8').toString('base64')}`
}
export async function createLoginSession({ state, codeChallenge }) {
const response = await fetch(`${config.backendUrl}/relying-party/v1/session`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: basicAuthorization(),
},
body: JSON.stringify({
redirect_uri: `${config.publicBaseUrl}/auth/callback`,
state,
signature_type: 'AUTHENTICATION',
code_challenge: codeChallenge,
code_challenge_method: 'S256',
}),
})
if (!response.ok) {
throw new Error(`Session creation failed (HTTP ${response.status})`)
}
return response.json()
}
randomBytes(32).toString('base64url') yields exactly 43 characters from the
allowed set, so it satisfies the format requirement without further work.
The Basic credential is standard base64 of client_id:client_secret — not
base64url, and not the code_challenge encoding.
Java
private static final SecureRandom RANDOM = new SecureRandom();
private static final Base64.Encoder ENCODER = Base64.getUrlEncoder().withoutPadding();
record PkcePair(String codeVerifier, String codeChallenge) {}
PkcePair createPkcePair() throws NoSuchAlgorithmException {
byte[] verifierBytes = new byte[32];
RANDOM.nextBytes(verifierBytes);
String codeVerifier = ENCODER.encodeToString(verifierBytes);
byte[] digest = MessageDigest.getInstance("SHA-256")
.digest(codeVerifier.getBytes(StandardCharsets.US_ASCII));
return new PkcePair(codeVerifier, ENCODER.encodeToString(digest));
}
String basicAuthorization() {
String credential = clientId + ":" + clientSecret;
return "Basic " + Base64.getEncoder().encodeToString(credential.getBytes(StandardCharsets.UTF_8));
}
CreateSessionResponse createLoginSession(String state, String codeChallenge) throws Exception {
Map<String, String> body = Map.of(
"redirect_uri", publicBaseUrl + "/auth/callback",
"state", state,
"signature_type", "AUTHENTICATION",
"code_challenge", codeChallenge,
"code_challenge_method", "S256");
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(backendUrl + "/relying-party/v1/session"))
.header("Content-Type", "application/json")
.header("Authorization", basicAuthorization())
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("Session creation failed: HTTP " + response.statusCode());
}
return objectMapper.readValue(response.body(), CreateSessionResponse.class);
}
Hash the ASCII characters of the verifier string, not the random bytes it
was encoded from. Hashing the raw bytes produces a challenge that will fail
verification at the token step with invalid_grant, and the mistake is easy to
miss because everything up to that point succeeds.
Step 2: Show the waiting page
Serve a page of your own that renders the QR code the ComplyOnce app scans. The
payload is built entirely from the sessionId:
complyonce://app/scan?sessionId=<uuid>&returnUrl=<url-encoded return address>
returnUrl is where the app should return the user on the same device — your own
waiting page, in a form that can pick the session back up. If the user is already
on their phone, offering that URI as a link rather than a QR code lets them hand
off to the app directly.
The page must not be given the sessionId in a form it can act on beyond
rendering: polling and completion are your server's job, so the page should ask
your server for progress rather than holding anything ComplyOnce accepts.
The session expires 120 seconds after it is created (a backend setting, so confirm the value for your environment). That covers scanning and PIN entry, so users who put their phone down mid-login will need to start over.
Step 3: Poll, then complete
Your server polls the session and relays a stripped-down view of it to your page. Two seconds is a sensible interval against a 120-second session:
curl https://<backend-host>/relying-party/v1/session/<sessionId>/status \
-u "$COMPLYONCE_CLIENT_ID:$COMPLYONCE_CLIENT_SECRET"
The statuses are PENDING, SCANNED, CONFIRMED, COMPLETED and EXPIRED —
the API reference
lists what each carries. Two of them need action:
SCANNED— the response now includes a four-digitpairingCode. Show it. The phone shows the same code together with your registeredname; the user compares them and enters their PIN. This is the defence against a relay attack: the code is revealed only after a scan and shown on both screens, so a user being walked through someone else's login sees codes that do not match.CONFIRMED— the user has approved. Complete the session:
curl -X POST https://<backend-host>/relying-party/v1/session/<sessionId>/completion \
-u "$COMPLYONCE_CLIENT_ID:$COMPLYONCE_CLIENT_SECRET"
{ "redirect": "https://portal.example.com/auth/callback?code=Yk9c…&state=xr7Kp2mQ9vB4nL1s" }
Send the browser to that URL. It is your own redirect_uri with code and
state attached, so your existing callback handling applies unchanged.
Completion is single-use: a second call returns 409 session_already_completed.
Treat that as "this login is already spent" rather than retrying.
Because your server holds the sessionId, you could equally read code out of
that URL and redeem it without involving the browser. Going through your callback
is worth the extra hop if you want one code path for logins, and it keeps the
state check below meaningful.
Step 4: Handle the callback
The browser arrives at your redirect_uri:
https://portal.example.com/auth/callback?code=Yk9c…&state=xr7Kp2mQ9vB4nL1s
Before anything else, compare state against the value you stored in step 1,
and reject the request if it is missing or different. Skipping this check is
what makes an integration vulnerable to a forged callback.
authRoutes.get('/auth/callback', async (request, response) => {
const login = decodeSignedCookie(request.cookies[LOGIN_COOKIE], config.sessionSecret)
response.clearCookie(LOGIN_COOKIE, cookieOptions())
if (!login) {
redirectHome(response, 'login_expired')
return
}
if (typeof request.query.code !== 'string') {
redirectHome(response, 'missing_code')
return
}
if (request.query.state !== login.state) {
redirectHome(response, 'state_mismatch')
return
}
// …redeem the code, see step 5
})
Two details worth copying: the login cookie is cleared as soon as it is read, so
one login attempt cannot be replayed; and request.query.state is checked to be
a string, because a repeated query parameter arrives as an array and would
otherwise sidestep the comparison.
Step 5: Redeem the code
The last server-to-server POST. The body carries only the code and the verifier; your identity comes from the same Basic credential as every other call.
curl -X POST https://<backend-host>/relying-party/v1/session/token \
-u "$COMPLYONCE_CLIENT_ID:$COMPLYONCE_CLIENT_SECRET" \
-H 'Content-Type: application/json' \
-d '{
"code": "Yk9cQ3RtV2hhdCBhIGxvdmVseSBkYXkgZm9yIGEgd2Fsaw",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
}'
{ "subjectId": "8f14e45f-ceea-4a1b-9f2c-3d5b7a081c62" }
subjectId is the whole response for a plain login. If you requested identity
data with the properties parameter, attributes and predicates accompany it
— see Identity properties.
The authorization code is single-use and expires 60 seconds after the login is
confirmed (again, a backend setting). Redeem it as the first thing your
callback handler does after checking state — do not queue it, and do not defer
it behind other work.
Node.js
export async function redeemAuthorizationCode({ code, codeVerifier }) {
const response = await fetch(`${config.backendUrl}/relying-party/v1/session/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: basicAuthorization(),
},
body: JSON.stringify({ code, code_verifier: codeVerifier }),
})
if (!response.ok) {
const body = await response.json().catch(() => null)
throw new Error(`Token exchange failed (HTTP ${response.status}): ${body?.errorCode ?? 'no error body'}`)
}
const { subjectId } = await response.json()
if (!subjectId) {
throw new Error('Token exchange returned no subjectId')
}
return subjectId
}
Java
UUID redeemAuthorizationCode(String code, String codeVerifier) throws Exception {
Map<String, String> body = Map.of(
"code", code,
"code_verifier", codeVerifier);
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(backendUrl + "/relying-party/v1/session/token"))
.header("Content-Type", "application/json")
.header("Authorization", basicAuthorization())
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() != 200) {
throw new IllegalStateException("Token exchange failed: HTTP " + response.statusCode()
+ " " + response.body());
}
return UUID.fromString(objectMapper.readTree(response.body()).get("subjectId").asText());
}
Note that the request body is JSON, not the application/x-www-form-urlencoded
that RFC 6749 specifies for a token endpoint. If you reach for a generic OAuth
client library, check how it encodes the token request before assuming it will
work here.
Step 6: Establish your session
Look up or create your account record keyed by subjectId, then start your own
session. Two things to get right:
- Regenerate the session identifier at this point. Reusing the pre-login identifier leaves you open to session fixation.
- Never accept a
subjectIdfrom the browser. It arrives only in the token response, on your server. Treat it the way you would treat a verified user id from any other identity provider.
What subjectId is, and is not
It is derived from your client_id together with the user's ComplyOnce identity,
under a key held by the backend. The identity behind it never leaves ComplyOnce.
Three properties follow, and they are worth designing around:
- Stable per user, per client. The same person logging in to you again gets
the same
subjectIdevery time, so it works as the primary key of your account record. - Yours alone. The same person logging in to a different relying party is given an unrelated value, and because the derivation is keyed you cannot compute or recognise it. Two relying parties comparing their records cannot tell they hold the same person.
- Scoped to one environment. The derivation key is per-environment
configuration, so the same person has a different
subjectIdin staging than in production. Account records do not transfer between environments, and test data captured against one is meaningless against another.
The flip side of the second property: a subjectId is opaque and not reversible
by you. There is nothing you can do with one except recognise the same user
returning, so anything else you need about that person has to come from
identity properties or from your own records.
Because stability rests on that key, a rotation of it on the ComplyOnce side would
change every subjectId you hold — an account-migration event, not a routine
change. If you are told the key is being rotated, treat it as one.
response.cookie(
SESSION_COOKIE,
encodeSignedCookie({ subjectId }, config.sessionSecret, SESSION_LIFETIME_SECONDS),
{ httpOnly: true, sameSite: 'lax', secure: config.isProduction, path: '/' },
)
If you use a signed cookie rather than server-side session storage, it must be
httpOnly, SameSite=Lax or stricter, Secure in production, and integrity
protected — HMAC-SHA256 with a secret that is not your ComplyOnce
client_secret.
The hosted login page
If you would rather not build the waiting page, ask for a handoff token when you create the session and send the browser to the page ComplyOnce operates.
Step 1 gains one field:
{
"redirect_uri": "https://portal.example.com/auth/callback",
"state": "xr7Kp2mQ9vB4nL1s",
"signature_type": "AUTHENTICATION",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256",
"hosted_page": true
}
and the response gains a handoffToken. Redirect the browser to the hosted page
carrying it:
https://<login-host>/?t=<handoffToken>
The page exchanges that token for a cookie of its own, renders the QR code, shows
the pairing code, polls, completes the session, and sends the browser on to your
redirect_uri with code and state. Step 2 and step 3 stop being yours.
Steps 4, 5 and 6 are unchanged. You still check state, still redeem the code
from your server with your own credentials, and still establish your own session.
The hosted page never sees your client_secret and never learns who the user is.
Three things to know:
- The handoff token is single-use and lives 60 seconds. Redirect immediately; do not store it, log it, or hand it out twice. It is a credential for one login.
- Ask for it only when you mean to use it. Without
hosted_pageno token is minted, and a session with no token cannot be claimed by any browser. - A browser drives one hosted login at a time. Starting a second supersedes the first, and the abandoned page says so rather than quietly following a login the user did not begin there.
Timing
| Deadline | Default | What it limits |
|---|---|---|
| Session validity | 120 seconds | From session creation to the user confirming on their phone. |
| Authorization code validity | 60 seconds | From confirmation to your token request. |
| Handoff token validity | 60 seconds | From session creation to the hosted page claiming it. Hosted page only. |
All three are backend configuration, so treat the numbers as the current defaults rather than guarantees, and confirm them for your environment.
Failure handling
Design the callback so that every failure lands the user somewhere sensible — usually your login page with a short message. The flow has several ways to end that are not your bug:
| Situation | What you see | Reasonable response |
|---|---|---|
| User never scanned, or scanned too late | Status goes to EXPIRED |
Tell the waiting page; the user starts again. |
| User abandoned the login | Status stays PENDING until it expires |
Stop polling when the session expires. |
| Login cookie expired or missing | Callback with no matching stored state | Send them back to the login page and start over. |
state does not match |
Mismatch on comparison | Abort. Do not redeem the code. |
| Session already completed | 409 session_already_completed |
The login is spent; do not retry completion. |
| Code already redeemed or expired | 400 invalid_grant |
Ask the user to log in again. |
| PKCE verification failed | 400 invalid_grant |
Almost always the hashing mistake described in step 1. |
| Credentials wrong or missing | 401 with WWW-Authenticate: Basic, and no errorCode in the body |
Configuration error on your side — alert, don't retry. |
Note the last row: because authentication is checked before the handler runs, a
bad credential does not produce the error envelope. Code that assumes every
non-2xx response has an errorCode will throw on the one failure most likely to
happen on your first deployment.
Everything that does reach a handler carries a body shaped like this:
{
"errorCode": "invalid_grant",
"defaultTranslation": "Invalid or expired authorization code",
"resultMessageLangCode": "en",
"payload": null
}
Log errorCode and the HTTP status; both are stable enough to alert on.
defaultTranslation is a developer-facing message — do not show it to users.
Do not retry a failed token exchange with the same code. It is single-use, so a retry cannot succeed; if the first call did succeed and you lost the response, the login is gone and the user must start over.
Security checklist
statecompared on every callback, and generated with a CSPRNG.- A fresh
code_verifierper login attempt, never reused. code_verifierandstatestored server-side or in a signed,httpOnly, short-lived cookie — never inlocalStorageor a URL.client_secretonly ever in anAuthorizationheader sent from your server, loaded from the environment, absent from logs. It is now used on every ComplyOnce call, so it has more chances to leak than before — check that your HTTP client does not log request headers.sessionIdnever accepted from the browser. Your server created it and knows which login it belongs to; taking it back from a request would let one user poll or complete another's login.- Session identifier regenerated after login.
- HTTPS everywhere in production, including the registered
redirect_uri. - The whole flow driven by your server; the browser is told nothing until the code is redeemed.
The last two points are about hosting the waiting page yourself. On the hosted
page the equivalent guarantees are the backend's: the browser holds a cookie
scoped to one login, and the handoffToken is the one value you must treat as a
credential — redirect with it immediately and keep it out of your logs.
A working reference
The complyonce-playground repository is the one to read. It runs a real login
against the current flow and shows every request and response as it happens —
session creation, the status polls, completion, the token exchange — next to the
state, code_verifier and code_challenge behind them. It also has toggles
that break one thing on purpose, so you can see what a wrong client_secret, an
unregistered redirect_uri or a mismatched code_verifier actually return
before you meet them in your own logs.
The complyonce-minigames repository is a smaller example: an Express
backend-for-frontend that owns the login, with a static frontend that only asks
"am I logged in?". Read it with one caveat: its session creation is a retired
flow, in which the browser was sent to a ComplyOnce-hosted page carrying the
OAuth parameters and a client_id, and that page created and polled the session
on its own. Do not mistake it for the hosted page described above, which is
handed a single-use token by your server and never identifies a relying party by
itself. Everything else there — pkce.js, signedCookie.js, the
state-checking in authRoutes.js, and the token exchange — is the current
pattern.
The examples on this page are illustrative rather than extracted from a running integration — treat them as a starting point and verify against the API reference.