Passkey Authentication with Backend SDKs
This guide is meant for developers that are NOT using Descope on the frontend to design login screens and authentication methods.
If you'd like to use Descope Flows, Quick Start should be your starting point. If you'd like to use our Client SDKs, refer to our Client SDK docs.
Passkeys let users authenticate with phishing-resistant credentials based on WebAuthn. These credentials can be created and used through built-in device authenticators, such as fingerprint, facial recognition, or device PIN, as well as external security keys, such as YubiKeys or other FIDO-compatible hardware authenticators.
When implementing passkey authentication with Descope Backend SDKs, your application is responsible for coordinating the browser or native app passkey ceremony and then sending the resulting WebAuthn response to Descope.
A typical backend SDK implementation includes the following flows:
- Start and finish passkey sign-up
- Start and finish passkey sign-in
- Add a passkey to an existing user
- Validate the resulting Descope session
Backend SDK
Install SDK
npm i --save @descope/node-sdkImport and initialize SDK
import DescopeClient from '@descope/node-sdk';
try{
// baseUrl="<URL>" // When initializing the Descope client, you can also configure the baseUrl ex: https://auth.company.com - this is useful when you utilize a custom domain within your Descope project.
const descopeClient = DescopeClient({ projectId: '__ProjectID__' });
} catch (error) {
// handle the error
console.log("failed to initialize: " + error)
}
Start Sign-Up
Start the passkey sign-up flow by calling the sign-up start function.
This function requires a unique loginId, such as an email address or phone number. Descope uses this value as the user's login ID and associates the passkey credentials with it.
The function also requires an origin value. This should be the value of window.location.origin from your application client. Descope validates this origin against the domain configured in the Descope console. The origin must match the configured domain or be a valid subdomain.
// Args:
// loginId: email or phone - becomes the loginId for the user from here on and also used for delivery
const loginId = "email@company.com"
// origin: This is the origin of the signup request and the value should be window.location.origin from the client. This value is essential to protect against replay attacks where the start request and finish request can be validated to be from same domain.
const origin = "https://example.com"
// displayName: Display name to utilize for the user
const displayName = "Joe Person"
// loginOptions (LoginOptions): this allows you to configure behavior during the authentication process.
const loginOptions = {
"stepup": false,
"mfa": false,
"customClaims": {"claim": "Value1"},
"templateOptions": {"option": "Value1"}
}
// refreshToken (optional): the user's current refresh token in the event of stepup/mfa
const resp = await descopeClient.auth.webauthn.signUp.start(loginId, origin, displayName, loginOptions);
if (!resp.ok) {
console.log("Unable to start webauthn sign-up")
console.log("Status Code: " + resp.code)
console.log("Error Code: " + resp.error.errorCode)
console.log("Error Description: " + resp.error.errorDescription)
console.log("Error Message: " + resp.error.errorMessage)
}
else {
console.log("Successfully started webauthn sign-up")
console.log(resp)
}Finish Sign-Up
After starting the sign-up flow, Descope returns a transactionId. Your frontend must complete the passkey creation ceremony in the browser or native app, then send the resulting WebAuthn credential response back to your backend.
Use the transactionId and the credential response to finish sign-up.
// Args:
// transactionId: The transaction ID returned by the sign_up_start function
const transactionId = "xxxxxx"
// response: The response returned by successful biometric authorization in the browser
const response = '{"id":"","rawId":"","type":"public-key","response":{"authenticatorData":"","clientDataJSON":"","signature":"","userHandle":""}}'
const resp = await descopeClient.auth.webauthn.signUp.finish(transactionId, response);
if (!resp.ok) {
console.log("Unable to finish webauthn sign-up")
console.log("Status Code: " + resp.code)
console.log("Error Code: " + resp.error.errorCode)
console.log("Error Description: " + resp.error.errorDescription)
console.log("Error Message: " + resp.error.errorMessage)
}
else {
console.log("Successfully finished webauthn sign-up")
console.log(resp)
}Start Sign-In
Start the passkey sign-in flow by calling the sign-in start function.
This function requires the user's loginId, such as their email address or phone number. It also requires an origin value, which should be the value of window.location.origin from your application client.
Descope validates the origin against the domain configured in the Descope Console. The origin must match the configured domain or be a valid subdomain.
// Args:
// loginId: email or phone - the loginId for the user from here on and also used for delivery
const loginId = "email@company.com"
// origin: This is the origin of the signup request and the value should be window.location.origin from the client. This value is essential to protect against replay attacks where the start request and finish request can be validated to be from same domain.
const origin = "https://example.com"
const resp = await descopeClient.auth.webauthn.signIn.start(loginId, origin);
if (!resp.ok) {
console.log("Unable to start webauthn sign-in")
console.log("Status Code: " + resp.code)
console.log("Error Code: " + resp.error.errorCode)
console.log("Error Description: " + resp.error.errorDescription)
console.log("Error Message: " + resp.error.errorMessage)
}
else {
console.log("Successfully started webauthn sign-in")
console.log(resp)
}Finish Sign-In
After starting the sign-in flow, Descope returns a transactionId. Your frontend must complete the passkey authentication ceremony in the browser or native app, then send the resulting WebAuthn credential response back to your backend.
Use the transactionId and credential response to finish sign-in.
// Args:
// transactionId: The transaction ID returned by the sign in start function
const transactionId = "xxxxxx"
// response: The response returned by successful biometric authorization in the browser
const response = '{"id":"","rawId":"","type":"public-key","response":{"authenticatorData":"","clientDataJSON":"","signature":"","userHandle":""}}'
const resp = await descopeClient.auth.webauthn.signIn.finish(transactionId, response);
if (!resp.ok) {
console.log("Unable to finish webauthn sign-in")
console.log("Status Code: " + resp.code)
console.log("Error Code: " + resp.error.errorCode)
console.log("Error Description: " + resp.error.errorDescription)
console.log("Error Message: " + resp.error.errorMessage)
}
else {
console.log("Successfully finished webauthn sign-in")
console.log(resp)
}Sign-Up or Sign-In
Use SignUpOrInStart when you want a single passkey flow and do not need to decide upfront whether the user is signing up or signing in. Descope checks whether a user with the given loginID already has a passkey registered and returns the appropriate WebAuthn ceremony options.
The response includes a create field that tells your application how to complete the flow:
create: true— the user is new. On the client, callnavigator.credentials.createwith the returnedoptions, then callSignUpFinish.create: false— the user already exists. On the client, callnavigator.credentials.getwith the returnedoptions, then callSignInFinish.
There is no SignUpOrInFinish method. The finish step always uses SignUpFinish or SignInFinish, depending on the value of create.
// Args:
// loginId: email or phone - becomes the loginId for the user from here on and also used for delivery
const loginId = "email@company.com"
// origin: This is the origin of the request and the value should be window.location.origin from the client.
const origin = "https://example.com"
// loginOptions (LoginOptions): (optional) configure behavior during the authentication process, such as tenantId for tenant user isolation.
const loginOptions = {
"tenantId": "tenant1",
}
const resp = await descopeClient.auth.webauthn.signUpOrIn.start(loginId, origin, undefined, loginOptions);
if (!resp.ok) {
console.log("Unable to start webauthn sign-up or sign-in")
console.log("Status Code: " + resp.code)
console.log("Error Code: " + resp.error.errorCode)
console.log("Error Description: " + resp.error.errorDescription)
console.log("Error Message: " + resp.error.errorMessage)
}
else {
console.log("Successfully started webauthn sign-up or sign-in")
console.log(resp)
}
// Return resp to your client. The client should:
// - parse resp.data.options as the publicKey option for navigator.credentials.create or .get, based on resp.data.create
// - POST the resulting credential back to your server
// ---- Complete the flow (signUp.finish or signIn.finish, depending on resp.data.create) ----
// transactionId comes from signUpOrIn.start; response is the credential response from the browser.
const transactionId = resp.data.transactionId
const response = '{"id":"","rawId":"","type":"public-key","response":{"authenticatorData":"","clientDataJSON":"","signature":"","userHandle":""}}'
let authInfo
if (resp.data.create) {
authInfo = await descopeClient.auth.webauthn.signUp.finish(transactionId, response)
} else {
authInfo = await descopeClient.auth.webauthn.signIn.finish(transactionId, response)
}
if (!authInfo.ok) {
console.log("Unable to finish webauthn sign-up or sign-in")
console.log("Status Code: " + authInfo.code)
console.log("Error Code: " + authInfo.error.errorCode)
console.log("Error Description: " + authInfo.error.errorDescription)
console.log("Error Message: " + authInfo.error.errorMessage)
}
else {
console.log("Successfully finished webauthn sign-up or sign-in")
console.log(authInfo)
}Start Add User Device
Use Start Add User Device to add a new passkey or authenticator to an existing user account.
This flow is useful when a user has already authenticated with another method and wants to register a passkey for future sign-ins. The function requires a valid refresh token for the authenticated user.
// Args:
// loginId: email or phone - the loginId for the user
const loginId = "email@company.com"
// origin: This is the origin of the signup request and the value should be window.location.origin from the client. This value is essential to protect against replay attacks where the start request and finish request can be validated to be from same domain.
const origin = "https://example.com"
// refreshToken: Valid refresh_token for this user from another authentication method. This is required and should be extracted from query.
const refreshToken = "xxxxx"
const resp = await descopeClient.auth.webauthn.update.start(loginId, origin, refreshToken);
if (!resp.ok) {
console.log("Unable to start webauthn update")
console.log("Status Code: " + resp.code)
console.log("Error Code: " + resp.error.errorCode)
console.log("Error Description: " + resp.error.errorDescription)
console.log("Error Message: " + resp.error.errorMessage)
}
else {
console.log("Successfully started webauthn update")
console.log(resp)
}Finish Add User Device
After starting the add-device flow, Descope returns a transactionId. Your frontend must complete the passkey registration ceremony in the browser or native app, then send the resulting WebAuthn credential response back to your backend.
Use the transactionId and credential response to finish adding the passkey to the user's account.
// Args:
// transactionId: The transaction ID returned by the sign in start function
const transactionId = "xxxxxx"
// response: The response returned by successful biometric authorization in the browser
const response = '{"id":"","rawId":"","type":"public-key","response":{"authenticatorData":"","clientDataJSON":"","signature":"","userHandle":""}}'
const resp = await descopeClient.auth.webauthn.update.finish(transactionId, response);
if (!resp.ok) {
console.log("Unable to finish webauthn update")
console.log("Status Code: " + resp.code)
console.log("Error Code: " + resp.error.errorCode)
console.log("Error Description: " + resp.error.errorDescription)
console.log("Error Message: " + resp.error.errorMessage)
}
else {
console.log("Successfully finished webauthn update")
console.log(resp)
}Session Validation
After completing passkey sign-up or sign-in, validate the user session on your backend.
Descope provides session management capabilities, including session validation, configurable session timeouts, and logout support.
For backend session validation examples, see Session Validation with Backend SDKs.
Checkpoint
Your application is now integrated with Descope. Please test with sign-up or sign-in use case.