Unstable API: endpoints and payloads are still changing. Expect breaking changes without notice, and plan to update your integration.

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

  1. Your server generates a state and a PKCE pair, then creates a session on the ComplyOnce backend and gets back a sessionId.
  2. Your server renders a waiting page showing the QR code built from that sessionId. The user scans it with the ComplyOnce app.
  3. 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.
  4. Your server sees CONFIRMED, completes the session, and receives a redirect URL carrying code and state.
  5. Your server verifies state, then exchanges code for a subjectId.
  6. 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 EXPIREDthe API reference lists what each carries. Two of them need action:

  • SCANNED — the response now includes a four-digit pairingCode. Show it. The phone shows the same code together with your registered name; 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 subjectId from 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 subjectId every 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 subjectId in 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_page no 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

  • state compared on every callback, and generated with a CSPRNG.
  • A fresh code_verifier per login attempt, never reused.
  • code_verifier and state stored server-side or in a signed, httpOnly, short-lived cookie — never in localStorage or a URL.
  • client_secret only ever in an Authorization header 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.
  • sessionId never 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.