Passkey Integration Guide
Add phishing-resistant, biometric passkeys to your RiskOS™ workflows through the Digital Intelligence Web SDK, using WebAuthn registration and assertion ceremonies.
Overview
The Socure Passkey Service provides passkey lifecycle and ceremony execution. You own your user records; the service has no knowledge of them and stores no user profile data. The service executes the WebAuthn registration and assertion ceremonies and stores the resulting credentials, keyed by the identifiers you supply (account, relying party, and your user identifier).
The service attests proof of possession: for the identifiers you supply, it confirms whether the end user proved possession of a matching registered passkey. It does not make an authentication or access decision. You decide whether a successful possession proof authenticates the user for your system.
You integrate through the Socure Digital Intelligence Web SDK and your existing RiskOS™ workflows, in two phases:
- Registration (first visit) creates a passkey and stores the credential.
- Assertion (return visit) proves possession of an existing credential.
The following sections detail both phases. Passkeys give your returning users a phishing-resistant, biometric credential.
Key terms
-
relyingPartyId(rpId) — Your own domain, the relying party the passkey is scoped to (for example,yourdomain.com). You provide it to the SDK on eachregisterPasskey/assertPasskeycall, and to your RiskOS™ workflows. It must be a domain you control, chosen so that every origin that runs a ceremony is that domain or a subdomain of it, and it must be consistent across registration and assertion. It is never a Socure domain. The service never infers it from anything else, so it is required on every call.Supply it as a bare domain:
yourdomain.com, nothttps://yourdomain.com/. A value carrying a scheme, port, path, or trailing dot is rejected, as is a public suffix such ascom,co.uk, orgithub.io, since anrpIdthere would scope your credentials across every site under it. A subdomain is a valid choice:app.yourdomain.comscopes credentials to that host and anything beneath it, so pick the parent when your flows span sibling subdomains. Surrounding whitespace and letter case are normalized, soYourDomain.comandyourdomain.comare the same relying party. Supply an internationalized domain in its punycode form, since that is the form the browser reports.The SDK parameter is named
relyingPartyId; the field is namedrpIdin the RiskOS™ workflow contracts and anywhere the underlying WebAuthn options are visible, matching the WebAuthn specification'srp.id, and it is sent asdata.custom.rp_idon the RiskOS™ evaluation request. All name the same value. -
customerUserId— Your own identifier for the user, from your system. You provide it when requesting apasskeyUserToken(registration) and again when redeeming apasskeyResultId, so RiskOS™ can tie the passkey result back to the right user. It is sent asdata.custom.customer_user_idon the RiskOS™ evaluation request. Socure treats it as opaque and does not interpret it. -
sessionToken— The Digital Intelligence token from the DI Web SDK (getSessionToken()). It links the passkey activity to the active DI session and is passed to your RiskOS™ workflow, as with other DI modules today. -
passkeyUserToken— A single-use, short-lived JWT (approximately 5-minute TTL) returned by the RiskOS™ registration workflow on completion. It binds an upcoming registration to a specific user and relying party. You pass it straight intoregisterPasskey(). Single-use is enforced: one token drives at most one registration ceremony, and it is spent the moment the ceremony begins, whether or not the ceremony succeeds. It is not a per-user credential but a per-registration one, so registering a second device means running the registration workflow again for a second token (see Register more than one device). -
Registration — The first-visit phase: RiskOS™ risk-assesses the user, a passkey is created on their device, and the credential is stored on the Socure backend. This workflow includes a Passkey Registration step.
-
Assertion — The return-visit phase: the SDK runs the passkey ceremony in which the user proves possession of an existing credential, and RiskOS™ has the result checked. This workflow includes a Passkey Assertion step.
-
passkeyResultId— A single-use, short-lived (approximately 5-minute TTL) reference the SDK returns once a ceremony reaches the Socure backend, whether or not the passkey verified. You redeem it against RiskOS™ to learn the outcome, sending it asdata.custom.passkey_result_idon the RiskOS™ evaluation request. Redemption consumes the reference and is scoped by the account andrelyingPartyId: if no matching result exists (expired, already redeemed, never existed, or arelyingPartyIdthat does not match the ceremony) redemption returns404. Otherwise it returns an outcome ofverifiedorfailed, with thecustomerUserIdchecked as part of that result (see Phase 2, Step 3). A ceremony that never reaches the backend, for example one the user cancels or that finds no passkey, produces nopasskeyResultIdat all.
How it fits together
You integrate with two surfaces only:
- The RiskOS™ workflows — called as you do today. RiskOS™ manages the integration with the Passkey service.
- The DI Web SDK — which invokes the passkey registration and assertion ceremonies in the browser through
registerPasskey()andassertPasskey(), and returns apasskeyResultId.
The SDK orchestrates each ceremony: it calls the service and invokes the browser's WebAuthn APIs. Challenge generation, attestation and assertion validation, and credential storage all happen on the Socure backend, not in the SDK.
Prerequisites
-
The DI Web SDK integrated into your application, with access to the active Digital Intelligence
sessionToken(getSessionToken()), as you do for other DI modules today. -
Your relying party ID (your domain) to pass to the SDK and to your RiskOS™ workflows.
-
Your relying party registered with Socure before your first registration. Socure provisions your
relyingPartyIdtogether with the exact list of origins your app is served from (your web origins, and your app signing identities for native). Registration cannot be requested for a relying party that has not been provisioned, so this is a setup step, not something the first call creates.Two rules govern that list, and the first one constrains how you pick your
relyingPartyId. Every web origin must be therelyingPartyIditself or a subdomain of it.https://app.yourdomain.comandhttps://www.yourdomain.comcan both sit underyourdomain.com, but an origin on a different registrable domain cannot be added to that relying party at all. If your flows run on two unrelated domains, that is two relying parties, provisioned separately, with separate credentials: a passkey registered under one is invisible to the other. Browsers enforce the same relationship locally, so this is less a Socure restriction than the shape WebAuthn already has.Origins are matched exactly, by scheme and host, plus port when it is not the default. Every origin that runs a ceremony must be on the list, so
https://app.yourdomain.comdoes not coverhttps://www.yourdomain.com. Origins must behttps, must carry no path, query, or fragment, and must include a non-default port exactly as the browser reports it, as inhttps://app.yourdomain.com:8443. Adding an origin later is a Socure-side change, not something your app can do at call time. -
Your Socure account enabled for passkeys. The passkey endpoints reject accounts that have not been enabled, so confirm enablement with your Socure contact before your first workflow call.
-
Your RiskOS™ registration and assertion workflows with the passkey steps enabled.
Phase 1 — Registration
Registration maps to your registration workflow: RiskOS™ risk-assesses the user, and on completion the workflow issues a passkeyUserToken.
sequenceDiagram
participant App as Your app
participant SDK as DI Web SDK
participant RiskOS as RiskOS™ workflow
participant Backend as Socure Passkey Service
App->>RiskOS: Run registration workflow (phone, sessionToken, customerUserId, rpId, PII)
RiskOS->>RiskOS: Risk checks (phone risk, SNA/OTP step-up, DI, allow/deny list)
RiskOS-->>App: passkeyUserToken (single-use, ~5 min)
App->>SDK: registerPasskey(passkeyUserToken, relyingPartyId, ...)
SDK->>Backend: Start registration ceremony
Backend-->>SDK: WebAuthn options + challenge
SDK-->>App: PasskeyResult (passkeyResultId)
App->>RiskOS: Confirm registration (passkeyResultId, customerUserId)
RiskOS-->>App: outcome (verified | failed)
Step 1 — Run the RiskOS™ registration workflow
Call your RiskOS™ registration workflow as usual, passing the phone number, the DI sessionToken, the customerUserId, your relyingPartyId, and any additional PII. RiskOS™ runs its risk checks, such as phone risk, SNA/OTP step-up, Digital Intelligence, and allow/deny list.
On completion, the Passkey Registration step returns a single-use passkeyUserToken (a short-lived JWT, approximately 5-minute TTL) in the RiskOS™ response. This token binds the upcoming registration to the correct user and relying party.
The relyingPartyId is validated at this point. A malformed value, or one that has not been provisioned for your account, fails here rather than later in the ceremony, so a configuration mistake surfaces on that workflow call.
The passkey enrichment requires two data.custom fields on this call. Other data fields depend on the rest of your workflow (the phone, sessionToken, and PII noted above); passkey itself requires only these.
{
"data": {
"custom": {
"rp_id": "{{rpId}}",
"customer_user_id": "{{customerUserId}}"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
data.custom.rp_id | String | Required | Relying party ID as a bare domain; the same value as the SDK's relyingPartyId. Validated on this call. |
data.custom.customer_user_id | String | Required | Your identifier for the user. Socure treats it as opaque. |
Step 2 — Create the passkey through the SDK
SigmaDeviceManager.registerPasskey(
passkeyUserToken: string,
relyingPartyId: string,
username?: string,
displayName?: string
): Promise<PasskeyResult>passkeyUserToken— the token returned by RiskOS™ in Step 1.relyingPartyId— your domain, the relying party the passkey is scoped to. Must match therelyingPartyIdused to start the registration workflow.username(optional) — sets the WebAuthnuser.name, the account identifier the browser prompt and the platform's credential picker display for this passkey.displayName(optional) — sets the WebAuthnuser.displayName, a human-friendly name the prompt renders. Distinct fromusernameand shown differently by the browser.
The SDK applies username and displayName in the browser, so the values never leave the device: Socure does not receive, store, or verify them, and they are not part of the ceremony result. Supplying them is still worth doing. When you omit them, the prompt falls back to the display name provisioned for your relying party, which is identical for every user and every credential, so nothing in the prompt tells one passkey from another.
Because you supply the relyingPartyId in two places, the service cross-checks the value passed here against the one bound into the passkeyUserToken and rejects the call if they disagree. This happens before any passkey is created, so a typo or a www. divergence between your workflow and your app fails immediately instead of storing a credential your assertion calls would never find.
The SDK, working with the Socure backend, prompts the user to create the passkey and returns a PasskeyResult with the passkeyResultId. Challenge generation, attestation validation, and credential storage all happen on the Socure backend, not in the SDK.
Retrying:
The preceding cross-checks run before the
passkeyUserTokenis spent, so you can correct a mismatchedrelyingPartyIdor account and retry the call with the same token. Once the ceremony begins, the token is spent, so any failure past that point needs a newpasskeyUserTokenfrom a fresh registration workflow call. RetryingregisterPasskey()with a spent token is rejected exactly as an expired one is, so treat "invalid or expired token" as "request a new one," not as "retry."
Step 3 — Confirm registration with RiskOS™
Submit the passkeyResultId to RiskOS™ to confirm the credential was stored. This is the same redemption path used in assertion, described later in the assertion flow: it returns an outcome of verified or failed, so a registration whose ceremony did not validate comes back as failed rather than as an error, and no credential was stored in that case. The passkeyResultId is single-use, with an approximately 5-minute TTL; submit it promptly, as an expired or already-redeemed ID returns 404.
Send the passkeyResultId in the data.custom block, along with the same rp_id and customer_user_id used to start registration:
{
"data": {
"custom": {
"rp_id": "{{rpId}}",
"customer_user_id": "{{customerUserId}}",
"passkey_result_id": "{{passkeyResultId}}"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
data.custom.rp_id | String | Required | The relying party the passkey was registered under. |
data.custom.customer_user_id | String | Required | The user the credential belongs to. |
data.custom.passkey_result_id | String | Required | The passkeyResultId from the registration ceremony. Single-use, ~5-min TTL. |
Register more than one device
A passkeyUserToken is single-use, but the user's identity is not. Requesting a token for a customerUserId that has already registered resolves to the same underlying user, so each additional device is one more registration workflow call, one more registerPasskey(), and one more stored credential. Later registrations do not replace earlier ones, and the service imposes no limit on how many a user holds.
If you can, supply a username. The credential picker labels each passkey with the user.name it was created with, so a user who holds passkeys for more than one of your accounts sees identical entries unless you give them something to tell apart.
Phase 2 — Assertion
Assertion maps to your assertion workflow.
sequenceDiagram
participant App as Your app
participant SDK as DI Web SDK
participant RiskOS as RiskOS™ workflow
participant Backend as Socure Passkey Service
App->>SDK: assertPasskey(relyingPartyId)
SDK->>Backend: Start assertion ceremony
Backend-->>SDK: WebAuthn options + challenge
SDK-->>App: PasskeyResult (passkeyResultId)
App->>RiskOS: Run assertion workflow (passkeyResultId, customerUserId)
RiskOS->>Backend: Check proof against account, rpId, customerUserId
Backend-->>RiskOS: outcome (verified | failed)
RiskOS-->>App: Decision
Step 1 — Run the passkey assertion through the SDK
SigmaDeviceManager.assertPasskey(relyingPartyId: string): Promise<PasskeyResult>relyingPartyId— your domain, the relying party to assert against. Must match therelyingPartyIdthe passkey was registered with. There is no token on this call, so this value alone identifies the relying party; passing a different one finds none of the credentials registered under the original.
The SDK, working with the Socure backend, prompts the user to select their passkey and returns a PasskeyResult with a single-use passkeyResultId. Challenge generation and assertion validation happen on the Socure backend, not in the SDK.
Step 2 — Redeem with RiskOS™
Submit the passkeyResultId to your RiskOS™ assertion workflow, with the same customerUserId the passkey was registered under. assertPasskey() takes no user identifier, since the user selects the passkey and the credential itself identifies them, so redemption is where the result is tied back to your user record. RiskOS™ has the result checked against the Passkey service. A verified outcome requires both that the ceremony validated and that the customerUserId you supplied is the one the asserting credential belongs to. If the credential proved possession but for a different customerUserId, the outcome is failed: the result existed and was consumed, it simply did not belong to the user you named.
Send the passkeyResultId in the data.custom block, along with the same rp_id and customer_user_id used at registration:
{
"data": {
"custom": {
"rp_id": "{{rpId}}",
"customer_user_id": "{{customerUserId}}",
"passkey_result_id": "{{passkeyResultId}}"
}
}
}| Field | Type | Required | Description |
|---|---|---|---|
data.custom.rp_id | String | Required | The relying party the passkey was registered under. |
data.custom.customer_user_id | String | Required | The user the credential belongs to. |
data.custom.passkey_result_id | String | Required | The passkeyResultId from the assertion ceremony. Single-use, ~5-min TTL. |
Step 3 — Handle the decision
- If the result is a successful possession proof, you can accept it as authentication and let the user proceed.
- If the user cannot prove possession, RiskOS™ drops into phone entry and step-up before a final decision, as drawn in the RiskOS™ workflow.
When you redeem a passkeyResultId, RiskOS™ returns the result. The response includes the passkeyResultId, the relyingPartyId (rpId), the completion timestamp (completedAt), and an outcome field. The outcome is one of:
verified— The ceremony validated and the passkey belongs to thecustomerUserIdyou supplied. Accept it as a possession proof.failed— The redemption did not verify. Either the ceremony did not validate (for example the credential was rejected, or the app's origin is not on your provisioned list), or it validated but the credential belongs to a differentcustomerUserIdthan the one you supplied. The two are not distinguished; treat anyfailedas "not proven for this user."
Two conditions arrive outside the outcome field; handle both as failures:
- No
passkeyResultId— The ceremony never reached the backend: the user canceled, or no passkey was available, so the SDK call itself failed and there is nothing to redeem. 404on redemption — ThepasskeyResultIdis past its approximately 5-minute TTL, was already redeemed, never existed, or therelyingPartyIddoes not match the ceremony.
Note:
expiredis reserved in the redemption contract but is not emitted today; expiry surfaces as a404. Do not branch on it.
Troubleshooting
| Symptom | Cause and resolution |
|---|---|
registerPasskey() is rejected before any browser prompt appears | The relyingPartyId passed to the SDK does not match the one bound into the passkeyUserToken, or the relying party is not provisioned for your account. Both are configuration mistakes, and both leave the token unspent, so correct the value and retry with the same token. |
| The browser prompt appears but the SDK call fails | The relyingPartyId is not a registrable suffix of the origin serving your app. The browser enforces that relationship locally, so the ceremony is rejected before it reaches Socure and no passkeyResultId is produced. An origin that is a valid suffix but simply not on your provisioned allow-list is different: that ceremony completes and redeems to a failed outcome, not an SDK failure. |
| The ceremony fails on one of your domains but works on another | That origin is probably not under the relyingPartyId it is running against. An origin must be the relying party or a subdomain of it, so a second registrable domain needs its own relying party and its own registrations. |
| Registration succeeded but assertion finds no passkey | The relyingPartyId at assertion differs from the one used at registration, a www. divergence being the usual cause. Credentials are scoped to the exact relying party. |
Redemption returns 404 | The passkeyResultId is older than approximately 5 minutes, was already redeemed, never existed, or the relyingPartyId does not match the one the ceremony ran under. |
Redemption returns a failed outcome | Either the passkey did not verify (the credential was rejected, or the app's origin is not on the provisioned list), or it verified but the customerUserId you submitted is not the one the credential belongs to. If you expected verified, confirm you submitted the same customerUserId the passkey was registered under, on the relyingPartyId it was registered against. |
| A second device cannot register | Confirm you requested a fresh passkeyUserToken; the previous one is spent. |
Related
Updated 3 days ago

