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 each registerPasskey/assertPasskey call, 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, not https://yourdomain.com/. A value carrying a scheme, port, path, or trailing dot is rejected, as is a public suffix such as com, co.uk, or github.io, since an rpId there would scope your credentials across every site under it. A subdomain is a valid choice: app.yourdomain.com scopes 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, so YourDomain.com and yourdomain.com are 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 named rpId in the RiskOS™ workflow contracts and anywhere the underlying WebAuthn options are visible, matching the WebAuthn specification's rp.id, and it is sent as data.custom.rp_id on the RiskOS™ evaluation request. All name the same value.

  • customerUserId — Your own identifier for the user, from your system. You provide it when requesting a passkeyUserToken (registration) and again when redeeming a passkeyResultId, so RiskOS™ can tie the passkey result back to the right user. It is sent as data.custom.customer_user_id on 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 into registerPasskey(). 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 as data.custom.passkey_result_id on the RiskOS™ evaluation request. Redemption consumes the reference and is scoped by the account and relyingPartyId: if no matching result exists (expired, already redeemed, never existed, or a relyingPartyId that does not match the ceremony) redemption returns 404. Otherwise it returns an outcome of verified or failed, with the customerUserId checked 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 no passkeyResultId at all.


How it fits together

You integrate with two surfaces only:

  1. The RiskOS™ workflows — called as you do today. RiskOS™ manages the integration with the Passkey service.
  2. The DI Web SDK — which invokes the passkey registration and assertion ceremonies in the browser through registerPasskey() and assertPasskey(), and returns a passkeyResultId.

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 relyingPartyId together 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 the relyingPartyId itself or a subdomain of it. https://app.yourdomain.com and https://www.yourdomain.com can both sit under yourdomain.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.com does not cover https://www.yourdomain.com. Origins must be https, must carry no path, query, or fragment, and must include a non-default port exactly as the browser reports it, as in https://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}}"
    }
  }
}
FieldTypeRequiredDescription
data.custom.rp_idStringRequiredRelying party ID as a bare domain; the same value as the SDK's relyingPartyId. Validated on this call.
data.custom.customer_user_idStringRequiredYour 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 the relyingPartyId used to start the registration workflow.
  • username (optional) — sets the WebAuthn user.name, the account identifier the browser prompt and the platform's credential picker display for this passkey.
  • displayName (optional) — sets the WebAuthn user.displayName, a human-friendly name the prompt renders. Distinct from username and 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 passkeyUserToken is spent, so you can correct a mismatched relyingPartyId or 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 new passkeyUserToken from a fresh registration workflow call. Retrying registerPasskey() 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}}"
    }
  }
}
FieldTypeRequiredDescription
data.custom.rp_idStringRequiredThe relying party the passkey was registered under.
data.custom.customer_user_idStringRequiredThe user the credential belongs to.
data.custom.passkey_result_idStringRequiredThe 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 the relyingPartyId the 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}}"
    }
  }
}
FieldTypeRequiredDescription
data.custom.rp_idStringRequiredThe relying party the passkey was registered under.
data.custom.customer_user_idStringRequiredThe user the credential belongs to.
data.custom.passkey_result_idStringRequiredThe 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 the customerUserId you 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 different customerUserId than the one you supplied. The two are not distinguished; treat any failed as "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.
  • 404 on redemption — The passkeyResultId is past its approximately 5-minute TTL, was already redeemed, never existed, or the relyingPartyId does not match the ceremony.
📘

Note:

expired is reserved in the redemption contract but is not emitted today; expiry surfaces as a 404. Do not branch on it.


Troubleshooting

SymptomCause and resolution
registerPasskey() is rejected before any browser prompt appearsThe 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 failsThe 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 anotherThat 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 passkeyThe 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 404The 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 outcomeEither 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 registerConfirm you requested a fresh passkeyUserToken; the previous one is spent.

Related


Did this page help you?