Passkeys
Use passkeys to offer phishing-resistant, passwordless authentication. ThunderID exposes WebAuthn-based APIs to register passkey credentials and to authenticate users with those credentials.
Overview
Passkeys are a modern, secure alternative to passwords based on the WebAuthn standard. They use public-key cryptography to provide:
- Phishing-resistant authentication: Passkeys are bound to your domain and cannot be used on fake sites
- Passwordless experience: Users authenticate with biometrics, PINs, or security keys instead of remembering passwords
- Cross-device compatibility: Passkeys sync across devices via platform authenticators (e.g., iCloud Keychain, Google Password Manager)
ThunderID supports passkeys through three approaches:
- ThunderID Gate (Hosted UI): Use ThunderID's hosted authentication pages (
gate) by configuring passkeys in your application settings, no custom UI or API calls needed - Direct API approach: Direct HTTP endpoints (
/register/passkey/*and/auth/passkey/*) for full control over the registration and authentication flow - Flow-based approach: Integrate passkeys into orchestrated authentication/registration flows via the
/flow/executeAPI, combining passkeys with other authentication methods
All approaches follow the WebAuthn standard ceremony:
- Registration: Generate a credential creation challenge, collect the attestation from the browser's
navigator.credentials.create(), and store the credential - Authentication: Generate an assertion challenge, collect the signed assertion from
navigator.credentials.get(), and verify it
Prerequisites
- Serve the UI over HTTPS with a hostname that matches your WebAuthn Relying Party ID (RP ID).
- Add allowed origins for WebAuthn to your deployment configuration. Example (
deployment.yaml):
passkey:
allowed_origins:
- "https://localhost:8090"
- "https://localhost:3000"
- Use a WebAuthn-capable browser (recent Chrome, Edge, Safari, or Firefox); you can confirm support at https://passkeys.dev/device-support/ or by checking
window.PublicKeyCredentialin the browser console. - Ensure users already exist or are created beforehand in ThunderID for passkey registration.
Use ThunderID Gate (Hosted UI)
The simplest way to enable passkeys is through ThunderID Gate, ThunderID's hosted authentication and registration UI. This approach uses OAuth2/OIDC authorization flow:
- Create an application in the ThunderID Console (or via the Application API).
- Configure authentication flows for the application:
- Navigate to Applications → Select your application → Flows tab
- Select an Authentication Flow that includes passkey authentication (e.g., "Passkey Authentication" or "Basic + Passkey Authentication and Registration Flow")
- Optionally, select a Registration Flow that includes passkey registration (e.g., "Passkey Registration Flow")
- The flow builder UI lets you customize which executors run and configure relying party settings
- Integrate with your application:
- Redirect users to ThunderID's OAuth2 authorize endpoint:
https://localhost:8090/oauth2/authorize?client_id=<your-client-id>&redirect_uri=<your-callback>&response_type=code&scope=openid - ThunderID automatically redirects to ThunderID Gate (e.g.,
https://localhost:5190/gate/signin) based on thegate_clientconfiguration indeployment.yaml - ThunderID Gate renders the authentication UI based on your selected flow, handling passkey WebAuthn ceremonies in the browser
- After successful authentication, users are redirected back to your application with an authorization code (exchange it for tokens via
/oauth2/token)
- Redirect users to ThunderID's OAuth2 authorize endpoint:
This approach requires no custom UI development or direct WebAuthn API calls from your application. ThunderID Gate (gate) handles:
- Rendering sign-in/registration prompts based on the configured flow
- Passkey registration during user sign-up (if registration flow includes passkey executor)
- Passkey authentication during sign-in
- Fallback to other authentication methods (password, social login, etc.) as defined in the flow
- All WebAuthn ceremony handling (
navigator.credentials.create()and.get())
When to use this approach:
- You want a quick setup without building custom authentication UIs
- You're using ThunderID as an OAuth2/OIDC provider for your applications
- You want ThunderID to manage the full authentication experience with customizable flows
When to use API-based approaches:
- You need full control over the UI/UX beyond what flow configuration offers
- You're building a mobile application or SPA with custom authentication flows that don't fit OAuth2 redirect flow
- You want to embed authentication directly in your application without redirects
Use Passkey Direct API in Your Application
The registration and authentication flows below describe the Direct API approach (direct
/register/passkey/*and/auth/passkey/*calls).
Registration Flow
Enrolling a passkey adds a credential that can then be used to sign in as the user, so registration
requires proof that the caller holds the account. Pass the assertion returned by a prior
authentication, such as a password or OTP login. The credential is enrolled only for the subject of
that assertion, so the assertion and userId must refer to the same user. The Direct-Auth-Secret
header is not sufficient on its own: it identifies the calling integration, not the end user.
This means a user needs an existing credential before they can enroll their first passkey through the Direct API. To register a passkey as the very first credential on an account, drive a registration flow instead.
- Start registration. Create WebAuthn creation options and a session token.
curl -k -X POST https://localhost:8090/register/passkey/start \
-H "Content-Type: application/json" \
-H "Direct-Auth-Secret: <secret>" \
-d '{
"userId": "<user-id>",
"assertion": "<assertion-from-a-prior-authentication>",
"relyingPartyId": "localhost",
"relyingPartyName": "ThunderID",
"authenticatorSelection": {
"userVerification": "preferred"
},
"attestation": "none"
}'
Response fields:
publicKeyCredentialCreationOptions: pass directly tonavigator.credentials.create()(after Base64URL→ArrayBuffer conversion).sessionToken: required for the finish call.
A start request whose assertion cannot be verified is rejected with AUTHN-1009, and one whose
assertion belongs to a different user with AUTHN-1010. Omitting assertion or userId entirely,
or sending either as an empty or blank string, is a required-field violation: those are rejected by
request validation with the INVALID_INPUT_METADATA code and an errors map naming the field.
-
Run the WebAuthn ceremony in the browser. Call
navigator.credentials.create()with the returned options. See the sample implementation insamples/apps/vanilla-sample/src/services/authService.ts. -
Finish registration. Send the attestation result with the session token.
curl -k -X POST https://localhost:8090/register/passkey/finish \
-H "Content-Type: application/json" \
-H "Direct-Auth-Secret: <secret>" \
-d '{
"publicKeyCredential": {
"id": "<credential-id>",
"type": "public-key",
"response": {
"clientDataJSON": "<base64url>",
"attestationObject": "<base64url>"
}
},
"sessionToken": "<session-token>",
"credentialName": "My laptop key"
}'
On success, the API returns passkey registration metadata for the newly created credential (CredentialID, CredentialName, and CreatedAt).
Authentication Flow
- Start authentication. Request assertion options.
curl -k -X POST https://localhost:8090/auth/passkey/start \
-H "Content-Type: application/json" \
-H "Direct-Auth-Secret: <secret>" \
-d '{
"userId": "<user-id-optional>",
"relyingPartyId": "localhost"
}'
userIdis optional for usernameless authentication.- Response fields:
publicKeyCredentialRequestOptions: pass tonavigator.credentials.get().sessionToken: required for finish.
-
Run a WebAuthn assertion in the browser. Call
navigator.credentials.get()with the options. -
Finish authentication. Send the assertion result with the session token.
curl -k -X POST https://localhost:8090/auth/passkey/finish \
-H "Content-Type: application/json" \
-H "Direct-Auth-Secret: <secret>" \
-d '{
"publicKeyCredential": {
"id": "<credential-id>",
"type": "public-key",
"response": {
"clientDataJSON": "<base64url>",
"authenticatorData": "<base64url>",
"signature": "<base64url>",
"userHandle": "<base64url>"
}
},
"sessionToken": "<session-token>",
"skipAssertion": false
}'
On success, the API returns an authentication response compatible with other ThunderID auth flows.
Use Passkeys with Flow/Execute
Passkeys also work through the flow engine (POST /flow/execute), which returns dynamic prompts and additional data.
Applications configured with the authorization_code grant type cannot start flows directly through POST /flow/execute. They must initiate authentication through the Authorization endpoint (GET /oauth2/authorize). See Integration Models.
Authentication with Flow/Execute
- Start the flow. Send an initial flow request.
curl -k -X POST https://localhost:8090/flow/execute \
-H "Content-Type: application/json" \
-d '{
"applicationId": "<app-id>",
"flowType": "AUTHENTICATION"
}'
- The response includes an
executionIdand a stepactionfor passkeys.data.additionalData.passkeyChallengecontains the WebAuthn request options. The passkey session token is stored server-side in the flow context runtime data and is managed by the server; the client does not need to read or send it.
-
Run WebAuthn in the browser. Call
navigator.credentials.get()with the decodedpasskeyChallenge. -
Continue the flow. Post the assertion back to the flow engine.
curl -k -X POST https://localhost:8090/flow/execute \
-H "Content-Type: application/json" \
-d '{
"executionId": "<execution-id-from-step>",
"action": "<action-ref-from-step>",
"inputs": {
"credentialId": "<credential-id>",
"clientDataJSON": "<base64url>",
"authenticatorData": "<base64url>",
"signature": "<base64url>",
"userHandle": "<base64url-optional>"
}
}'
- On success, the next response either completes the flow (with an assertion) or advances to the next step.
Registration with Flow/Execute
Passkey registration in a flow must run after the user is created or identified. Ensure the flow provisions the user (e.g., collecting username/email and running provisioning) or resolves an existing user before the passkey register start node, as shown in the bundled flow definitions.
- Start a registration flow. Send an initial flow request.
curl -k -X POST https://localhost:8090/flow/execute \
-H "Content-Type: application/json" \
-d '{
"applicationId": "<app-id>",
"flowType": "REGISTRATION"
}'
Reminder: Calling
/flow/executewith a flow that only runs the passkey registration executor is impractical; the flow must first provision or resolve the user so registration has a valid subject.
- The response includes an
executionId, a registration stepaction, anddata.additionalData.passkeyCreationOptionswith the WebAuthn creation options. The passkey session state is maintained server-side and associated with the flow, so clients only need to carryexecutionId(and the stepaction) between calls. Ensure the flow has already collected the user identifier before this step because registration requires a user ID.
-
Run WebAuthn in the browser. Call
navigator.credentials.create()with the decodedpasskeyCreationOptions. -
Finish registration in the flow. Post the attestation back to the flow engine.
curl -k -X POST https://localhost:8090/flow/execute \
-H "Content-Type: application/json" \
-d '{
"executionId": "<execution-id-from-step>",
"action": "<action-ref-from-step>",
"inputs": {
"credentialId": "<credential-id>",
"clientDataJSON": "<base64url>",
"attestationObject": "<base64url>",
"credentialName": "My laptop key"
}
}'
- On success, the response includes credential metadata (e.g.,
passkeyCredentialID) or advances to the next step of the flow.
Common Issues
- Origin mismatch: The browser origin must be listed under
passkey.allowed_originsand must match the RP ID domain. - HTTP instead of HTTPS: WebAuthn requires HTTPS in production.
- Stale session token: Use the
sessionTokenfrom the most recent start call for each ceremony. - Unsupported platform authenticator: Adjust
authenticatorSelection(e.g.,authenticatorAttachment) in the start request to match the target device. - User not found: Ensure the user exists in ThunderID before registering a passkey or start authentication with a valid user ID (unless using usernameless auth).