Private sign-in
Not yet availableAn 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.
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
openidandidentity_verified. Any other scope is refused by name:400PRIVATE_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:
400PRIVATE_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
| Where | What a private application gets |
|---|---|
| ID token | iss, sub, aud, exp, iat, nonce, identity_verified, consent_id, first_sign_in, connected_at (to the minute) |
| Access token | iss, sub, aud, exp, iat, token_use, developer_application_id, consent_record_id, scopes |
GET /v1/userinfo | sub and identity_verified |
| Webhooks | sign_in.completed, connection.created, oauth.authorization.completed, consent.granted, consent.revoked, webhook.test and the four billing.budget.* events |
| Developer console | The 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.
{
"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
}{
"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/pictureand/v1/profile-photo-comparisonsanswer403insufficient_scope, *Private sign-in releases userinfo only.* - When the person signed in to UnifyID. There is no
auth_time,amroracr, andmax_ageis refused, because two applications comparing those moments could match a person by them. - An address you suggested.
login_hintis dropped before the sign-in page, so an address you hold is never joined to thesub. - Webhook detail that names or dates the person. No
authorizationId, nodata.subject(the envelope'ssubject.idcarries thesub), noconsentDurationorconsentExpiresAt, and none of the document, claim, assurance or privacy-correction events.occurred_atis cut to the minute. - Why something was refused. A refusal about the person reads as
invalid_grantat the token endpoint and401invalid_tokenat userinfo. See The refusals. - Anything another application could match. The
subis random and made for your application alone, and the webhookcorrelation_idis your application's own.
The refusals
| When | What you get | refusal |
|---|---|---|
You ask for a scope other than openid and identity_verified | invalid_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 openid | invalid_scope: *<App> uses private sign-in, so it must ask for openid.* | private_sign_in_openid_required |
You send max_age | invalid_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 account | access_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 person | invalid_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 for | 401 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 person | Same `sub`? |
|---|---|
| disconnects you and connects again | Yes. first_sign_in is true again and connection.created is sent again |
| signs in on a new device, or adds or replaces a passkey | Yes |
| recovers their UnifyID account | Yes |
signs in with prompt=none | Yes |
| renews their identity document | Yes: the renewed document joins the ones already known |
| deletes their UnifyID account and registers again with the same identity document | Yes, 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 them | No. See the gap below |
| tries to open a second UnifyID account with a document their live account holds | Cannot: 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.
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.
<unifyid-continue
start-url="/api/auth/unifyid/start"
scopes="openid"
mode="popup"></unifyid-continue>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;
consentVersionon 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, expiresAtuntil_revokedand 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.
{
"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 privatesubis 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.