التوثيق منشور باللغة الإنجليزية.

DocumentationSign in

Private sign-in

Not yet available

An application that receives a `sub` and `identity_verified` and nothing else, and keeps one `sub` per person, even after they delete their UnifyID account and come back with the same document.

Two facts, and one person per sub

A private sign-in application learns two things about a person: sub, an identifier made only for that application, and identity_verified, that UnifyID has verified who they are. Nothing else reaches it: not in a token, in userinfo, at another endpoint, in a webhook, in the developer console or in an error.

What it gets in return is stability. One real person has one sub in your application, and cannot get a second one by opening another UnifyID account, because a document a live account holds cannot be used for another. The sub survives disconnecting and reconnecting, new devices and passkeys, account recovery and, in production, deleting the UnifyID account and registering again with the same identity document.

Chosen once, when the application is created

The mode belongs to the project. The New application screen sends it as signInMode on POST /v1/developer/applications: standard, the default, or private. The production record takes the sandbox record's mode. The mode never changes: a different signInMode on an update answers 409 SIGN_IN_MODE_FIXED, and a value other than the two is refused with 400. An existing application cannot become private; create a new one.

Create a private sandbox application (what the console sends)
POST /v1/developer/applications
content-type: application/json

{
  "name": "Acme Forum",
  "signInMode": "private",
  "redirectUris": ["https://localhost:8443/callback"],
  "allowedClaimScopes": ["openid", "identity_verified"]
}

What a private application may hold

allowedClaimScopes
Exactly openid and identity_verified. Any other scope is refused by name: 400 PRIVATE_SIGN_IN_SCOPE_NOT_ALLOWED, *A private sign-in application receives only openid and identity_verified. Remove: …*
allowedDataCategories
No document policy. The consent names identity and verification status, nothing more.
webhook events
The sign-in, connection, consent, test and budget events listed under Webhooks. Any other is refused: 400 PRIVATE_SIGN_IN_WEBHOOK_EVENT_NOT_ALLOWED.

The go-live checklist has one more check for a private application, private_sign_in_boundary (*Private sign-in boundary*), which passes while all three hold. It does not apply to a standard application.

What the application receives

WhereWhat a private application gets
ID tokeniss, sub, aud, exp, iat, nonce, identity_verified, consent_id, first_sign_in, connected_at (to the minute)
Access tokeniss, sub, aud, exp, iat, token_use, developer_application_id, consent_record_id, scopes
GET /v1/userinfosub and identity_verified
Webhookssign_in.completed, connection.created, oauth.authorization.completed, consent.granted, consent.revoked, webhook.test and the four billing.budget.* events
Developer consoleThe consent register, with every date as a day (YYYY-MM-DD), and who ended a consent only when it was your application

Ask for openid. identity_verified comes with every sign-in whether you ask for it or not, and asking for both is allowed; the token response's scope names both.

A private application's ID token payload
{
  "iss": "https://api.unifyid.example",
  "sub": "sub_Q2v…",
  "aud": "vid_live_example",
  "nonce": "…",
  "identity_verified": true,
  "consent_id": "c0ffee…",
  "first_sign_in": true,
  "connected_at": "2026-12-01T09:00:00.000Z",
  "iat": 1796115610,
  "exp": 1796116510
}
Its userinfo answer
{
  "sub": "sub_Q2v…",
  "identity_verified": true
}

What it never receives

  • Anything else about the person. No name, email address, phone number, date of birth, age or over-18 answer, nationality, assurance level, picture, identity document or document number. /v1/userinfo/identity-documents, /v1/userinfo/picture and /v1/profile-photo-comparisons answer 403 insufficient_scope, *Private sign-in releases userinfo only.*
  • When the person signed in to UnifyID. There is no auth_time, amr or acr, and max_age is refused, because two applications comparing those moments could match a person by them.
  • An address you suggested. login_hint is dropped before the sign-in page, so an address you hold is never joined to the sub.
  • Webhook detail that names or dates the person. No authorizationId, no data.subject (the envelope's subject.id carries the sub), no consentDuration or consentExpiresAt, and none of the document, claim, assurance or privacy-correction events. occurred_at is cut to the minute.
  • Why something was refused. A refusal about the person reads as invalid_grant at the token endpoint and 401 invalid_token at userinfo. See The refusals.
  • Anything another application could match. The sub is random and made for your application alone, and the webhook correlation_id is your application's own.

The refusals

WhenWhat you getrefusal
You ask for a scope other than openid and identity_verifiedinvalid_scope at your redirect URI: *<App> uses private sign-in and can ask only for openid. Remove: …*private_sign_in_scope
You do not ask for openidinvalid_scope: *<App> uses private sign-in, so it must ask for openid.*private_sign_in_openid_required
You send max_ageinvalid_request: *This application uses private sign-in, which does not accept max_age: when somebody last signed in to UnifyID is not shared.*private_sign_in_max_age
In production, the person has no verified identity document with a number, or one of their documents belongs to another live UnifyID accountaccess_denied, *The authorization request was not completed.* The person is told why on the UnifyID screen; you are not—
The token endpoint refuses for a reason about the personinvalid_grant, *Authorization grant is no longer valid.* A budget or credit refusal is still 402—
Userinfo refuses for the person's standing or a restriction they asked for401 invalid_token, *The OAuth access token is no longer authorized.*—

refusal is in the error's details where a refusal comes back as an API error. Your redirect URI receives only error, error_description and state, so branch on error. The description is written for a developer reading a log and changes without notice.

prompt works as it does for any application, prompt=none included.

When the sub stays the same

The personSame `sub`?
disconnects you and connects againYes. first_sign_in is true again and connection.created is sent again
signs in on a new device, or adds or replaces a passkeyYes
recovers their UnifyID accountYes
signs in with prompt=noneYes
renews their identity documentYes: the renewed document joins the ones already known
deletes their UnifyID account and registers again with the same identity documentYes, in production. As with any reconnection, first_sign_in is true and connection.created is sent
deletes their account and registers again with a document UnifyID has never seen for themNo. See the gap below
tries to open a second UnifyID account with a document their live account holdsCannot: the registration is refused

What UnifyID keeps after a deletion, one-way codes made from the document numbers and the subs, is kept while your application exists and for at most 5 years after the person's last private sign-in. Someone who comes back after that gets a new sub.

Treat a new sub as a new person. If somebody tells you they had an account before, handle it as you would without UnifyID: nothing you receive links the two.

The guarantee is production's

The sub is made per client id, so your sandbox and production records see different subs for the same person, as with a standard application. Recognising a person after an account deletion happens in production only: a sandbox application needs no identity document, and no document codes are kept for it, so do not test the deletion path there.

Testers are the one exception to not knowing who a person is. You invite a tester by their UnifyID, so while testing you can tell which sandbox sub is theirs. Production uses a different identifier.

No age gating

A private application cannot ask for age, age_over_18 or date_of_birth, and nothing it receives says how old the person is. identity_verified means UnifyID verified who they are, not that they are an adult. If your service has an age limit, private sign-in is the wrong mode: use a standard application and ask for age_over_18.

Keying your accounts

Key accounts on the issuer, your client id and the sub: the verified ID token's iss, aud and sub. Use a unique constraint and an atomic find-or-create. There is no email address to fall back on, and nothing else to link accounts by.

A keying sample (PostgreSQL)
CREATE TABLE app_users (issuer TEXT NOT NULL, client_id TEXT NOT NULL, sub TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT now(), UNIQUE (issuer, client_id, sub));
INSERT INTO app_users (issuer, client_id, sub) VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING *;
-- No row back: the person is already known. Read the row instead.
SELECT * FROM app_users WHERE issuer = $1 AND client_id = $2 AND sub = $3;

Sandbox and production are different client ids, so they are different rows. Never create an account because first_sign_in is true: it is true again after a reconnection, and after the person comes back from deleting their UnifyID account, when the row already exists. Bind each of your sessions to the ID token's consent_id, as Keep the account; end the revoked sessions describes.

With @vn.unifyid/connect

The drop-in component works unchanged. Ask for openid only, and have your start route enforce exactly that. Load the script as The drop-in component shows.

The element
<unifyid-continue
  start-url="/api/auth/unifyid/start"
  scopes="openid"
  mode="popup"></unifyid-continue>
Your start and complete routes, with @vn.unifyid/oauth-server
import { UnifyIDOAuthServer } from "@vn.unifyid/oauth-server";

const unifyid = new UnifyIDOAuthServer({
  issuer: process.env.UNIFYID_ISSUER,
  clientId: process.env.UNIFYID_CLIENT_ID,
  clientSecret: process.env.UNIFYID_CLIENT_SECRET,
  redirectUri: "https://app.example/oauth/callback",
  transactionStore,
});

// Start: ignore the scopes the browser sent. The SDK adds identity_verified.
// Never pass max_age in additionalParameters: it is refused.
const { authorizationUrl, state } = await unifyid.start({
  browserSessionId,
  scopes: ["openid"],
});

// Complete: userInfo is { sub, identity_verified } and nothing else.
const result = await unifyid.complete({ browserSessionId, callbackUrl });
const account = await findOrCreateAccount(result.issuer, result.clientId, result.subject);
await startSession(account, { consentId: result.consentId });

Webhooks

data on sign_in.completed, connection.created and oauth.authorization.completed

consentId, consentVersion
The consent in force and its version.
firstSignIn
As the ID token's first_sign_in.
connectedAt
ISO 8601, cut to the minute: when the connection was made.
completedAt
ISO 8601, cut to the minute.
scopes
What this sign-in asked for.

data on consent.granted and consent.revoked

consentId
The consent record; consentVersion on a grant.
status
The consent's status after the change.
scopes
The full set in force, or on a revocation everything that has just stopped.
duration, expiresAt
until_revoked and null.
occurredAt
ISO 8601, cut to the minute on a grant and to the hour on a revocation.

The envelope is the usual one. subject.id and actor.id are the person's sub, occurred_at is cut to the minute (to the hour on consent.revoked), and correlation_id is your application's own. A consent.revoked that follows the person deleting their UnifyID account, or restricting what UnifyID does with their data, is held back for a random 0 to 15 minutes, so its arrival does not line up with another application's. It is not in your delivery history, and cannot be retried, until it is released.

A private sign_in.completed delivery
{
  "id": "4d2f…",
  "type": "sign_in.completed",
  "version": "2.0",
  "occurred_at": "2026-12-01T09:00:00.000Z",
  "environment": "production",
  "application_id": "7a68073e-…",
  "actor": { "type": "person", "id": "sub_Q2v…" },
  "subject": { "type": "person", "id": "sub_Q2v…" },
  "correlation_id": "correlation_…",
  "data": {
    "consentId": "c0ffee…",
    "consentVersion": 1,
    "firstSignIn": true,
    "connectedAt": "2026-12-01T09:00:00.000Z",
    "completedAt": "2026-12-01T09:00:00.000Z",
    "scopes": ["openid", "identity_verified"]
  }
}

What this does not do

  • It is private from you, not from UnifyID. UnifyID still knows which person connected to your application; it is your application that cannot tell who they are. A mode in which UnifyID cannot tell either needs an app holding a key on the person's device, and is not built.
  • An existing application cannot switch. The mode is fixed at creation. A new private application gives everyone a new sub, because a private sub is never a standard application's pairwise one.
  • A person without a document number cannot use it in production. An account with no verified identity document carrying a number is refused with access_denied, so some people who can sign in to a standard application cannot sign in to yours.

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.