Server
Connect app ceremonies to server verification
Expo Easy Passkey runs the native ceremony. Your server owns the security model.
The server should:
- Create a fresh challenge for every registration and authentication attempt.
- Store the challenge until verification finishes.
- Verify the response from the app.
- Store credential public keys after registration.
- Check sign counters and replay attempts during authentication.
- Create the app session after authentication succeeds.
Endpoints
Use four endpoints. The names can change, but keep the options and verification steps separate.
POST /passkeys/register/options
POST /passkeys/register/verify
POST /passkeys/authenticate/options
POST /passkeys/authenticate/verifyOptions endpoints return an opaque ceremony identifier alongside WebAuthn JSON. Verify endpoints receive that identifier with the JSON returned by createPasskey or authenticateWithPasskey. The identifier lets the server select one pending ceremony without replacing or consuming another client's challenge.
The app snippets use fetch to show the boundary. The app asks for options, runs the native ceremony, and posts the result back. Your verifier still handles passkey security. These demos import fetch from expo/fetch, Expo's WinterCG-compliant Fetch API.
Example backend
This repo includes an API-only ElysiaJS implementation in apps/example-backend. It exports an Elysia server from src/index.ts, which follows Elysia's Vercel integration pattern.
pnpm --filter @repo/example-backend vercel:devOne caveat for local development: real native passkey ceremonies still require rp.id and origin to match an associated domain trusted by iOS and Android. A local backend can serve the API, but localhost generally cannot be the native passkey RP ID. For device testing, keep PASSKEY_RP_ID set to your associated domain and use a tunnel or hosted URL if the device must reach your machine.
It serves the passkey API and the platform trust files for the example domain:
GET /.well-known/apple-app-site-association
GET /.well-known/assetlinks.jsonConfigure it with environment variables when your relying-party domain differs from the committed example values:
PASSKEY_RP_ID=login.example.com
PASSKEY_ORIGIN=https://login.example.com
PASSKEY_RP_NAME="Example"
PASSKEY_CHALLENGE_TTL_MS=300000
APPLE_TEAM_ID=ABCDE12345
IOS_BUNDLE_IDENTIFIER=com.example.app
ANDROID_PACKAGE_NAME=com.example.app
ANDROID_SHA256_CERT_FINGERPRINTS=12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF,AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90:AB:CD:EF:12:34:56:78:90ANDROID_SHA256_CERT_FINGERPRINTS is a comma-separated allowlist. Each value must contain exactly 32 colon-separated certificate bytes. At startup, the example backend converts those bytes to unpadded base64url and builds an exact verification allowlist containing PASSKEY_ORIGIN plus one android:apk-key-hash:<base64url> origin per certificate. Invalid fingerprints or a PASSKEY_ORIGIN that is not an exact HTTPS origin stop startup.
The backend uses a concurrency-safe in-memory demo store for pending ceremonies and credentials. Distinct, expiring ceremony records make overlapping requests and single-use verification safe within one server process: verification claims a ceremony before credential writes, so two concurrent verifies of the same ceremony ID can produce at most one Passkey Credential or sign-counter update. Failed cryptographic verification releases the claim so a later retry can run. The state is not durable or coordinated across cold starts, multiple function instances, or redeploys. Treat it as a local demonstration, not production persistence; production deployments need a shared durable store with atomic conditional consumption.
Registration
import { fetch } from "expo/fetch";
import { createPasskey } from "expo-easy-passkey";
export async function addPasskey() {
const optionsResponse = await fetch(
"https://example.com/passkeys/register/options",
{ method: "POST" }
);
const { ceremonyId, options } = await optionsResponse.json();
const credential = await createPasskey(options);
const verifyResponse = await fetch(
"https://example.com/passkeys/register/verify",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ceremonyId, response: credential }),
}
);
if (!verifyResponse.ok) {
throw new Error(await verifyResponse.text());
}
}Registration options need challenge, rp, user, and the authenticator preferences your product uses. rp.id is the relying-party ID: the domain that owns and scopes the credential. For most apps, use your production sign-in domain, such as example.com.
{
"challenge": "cmVnaXN0cmF0aW9uLWNoYWxsZW5nZQ",
"rp": {
"id": "example.com",
"name": "Example"
},
"user": {
"id": "dXNlcl8xMjM",
"name": "lee@example.com",
"displayName": "Lee"
},
"pubKeyCredParams": [{ "type": "public-key", "alg": -7 }],
"authenticatorSelection": {
"authenticatorAttachment": "platform",
"residentKey": "preferred",
"userVerification": "preferred"
},
"attestation": "none",
"timeout": 60000
}Authentication
import { fetch } from "expo/fetch";
import { authenticateWithPasskey } from "expo-easy-passkey";
export async function signInWithPasskey() {
const optionsResponse = await fetch(
"https://example.com/passkeys/authenticate/options",
{ method: "POST" }
);
const { ceremonyId, options } = await optionsResponse.json();
const assertion = await authenticateWithPasskey(options);
const verifyResponse = await fetch(
"https://example.com/passkeys/authenticate/verify",
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ceremonyId, response: assertion }),
}
);
if (!verifyResponse.ok) {
throw new Error(await verifyResponse.text());
}
return verifyResponse.json();
}For passkey-first sign-in, return options without allowCredentials. rpId must be the same relying-party ID you used as rp.id during registration.
{
"challenge": "YXV0aGVudGljYXRpb24tY2hhbGxlbmdl",
"rpId": "example.com",
"userVerification": "preferred",
"timeout": 60000
}For username-first sign-in, return only credentials that belong to the selected account.
{
"challenge": "YXV0aGVudGljYXRpb24tY2hhbGxlbmdl",
"rpId": "example.com",
"allowCredentials": [
{
"id": "Y3JlZGVudGlhbF9pZA",
"type": "public-key",
"transports": ["internal"]
}
],
"userVerification": "required"
}SimpleWebAuthn
Many teams use @simplewebauthn/server on the backend. The RP ID and expected origins are related trust settings, but they are not the same value.
const rpID = "example.com";
const expectedOrigins = [
"https://example.com",
"android:apk-key-hash:EjRWeJCrze8SNFZ4kKvN7xI0VniQq83vEjRWeJCrze8",
];On Android, Credential Manager derives the origin from the installed app's signing certificate. Ceremony options or response fields must not add origins to this server-owned allowlist. The RP ID remains the associated domain (example.com) and is validated separately from either the HTTPS or Android origin.
Registration options:
import { generateRegistrationOptions } from "@simplewebauthn/server";
export async function registrationOptions(user: User) {
const options = await generateRegistrationOptions({
rpID: "example.com",
rpName: "Example",
userID: user.idBytes,
userName: user.email,
userDisplayName: user.name,
attestationType: "none",
authenticatorSelection: {
authenticatorAttachment: "platform",
residentKey: "preferred",
userVerification: "preferred",
},
excludeCredentials: user.passkeys.map((passkey) => ({
id: passkey.credentialId,
type: "public-key",
transports: passkey.transports,
})),
});
const ceremonyId = await createCeremony({
kind: "registration",
userId: user.id,
challenge: options.challenge,
expiresAt: Date.now() + 300_000,
});
return { ceremonyId, options };
}Registration verification:
import { verifyRegistrationResponse } from "@simplewebauthn/server";
export async function verifyRegistration(
user: User,
ceremonyId: string,
credential: unknown
) {
const ceremony = await loadCeremony({
ceremonyId,
kind: "registration",
userId: user.id,
});
const verification = await verifyRegistrationResponse({
response: credential,
expectedChallenge: ceremony.challenge,
expectedOrigin: expectedOrigins,
expectedRPID: "example.com",
});
if (!verification.verified || !verification.registrationInfo) {
throw new Error("Passkey registration failed");
}
await consumeCeremonyIfUnchanged(ceremony);
await savePasskey(user.id, verification.registrationInfo);
}Authentication verification follows the same pattern: load the saved credential, verify the assertion with the expected challenge, update the sign counter, then create the app session.
Server checklist
- Challenge values are single use.
- Challenge values expire quickly.
- Each options response has an opaque ceremony identifier bound to its kind, user, challenge, and expiry.
- Verification consumes the matching ceremony only after the response succeeds.
rp.id,rpId,expectedRPID, the iOS AASA file, and Android Digital Asset Links all use the same RP ID domain.expectedOriginis an exact server-owned allowlist containing the HTTPS origin and every trusted Android APK-key-hash origin.- Client-supplied ceremony data never expands the expected-origin allowlist.
- Credential IDs are stored as base64url strings or converted consistently at the server boundary.
- Authentication updates the stored sign counter when your verifier exposes one.