Skip to content

Passkey Registration and Validation Example

Passkeys let users authenticate with their fingerprint, face scan, or device PIN instead of passwords. This guide walks you through implementing the two essential flows:

  • Passkey registration - Saving a user’s passkey credential
  • Passkey authentication - Validating that passkey to log the user in

PropelAuth BYO handles the backend WebAuthn complexity, but you’ll need a frontend library to interact with the user’s device. This guide uses @simplewebauthn/browser for client-side operations.

Start by creating a backend endpoint that generates registration options. The Start Passkey Registration API returns registrationOptions that tell the browser how to create the passkey (whether to use Google Password Manager, iCloud Keychain, or the device’s built-in authenticator).

app.post("/api/passkey/register/start", async (req: Request, res: Response) => {
// Get user info from request (your logic)
const user = getUserInfoFromRequest(req);
const result = await auth.passkeys.startRegistration({
userId: user.userId,
emailOrUsername: user.email,
});
if (result.ok) {
res.json({ registrationOptions: result.data.registrationOptions });
} else {
console.error("Error starting registration:", result.error);
res.status(500).json({ error: "Failed to start passkey registration" });
}
});
@app.post("/api/passkey/register/start")
async def passkey_register_start(request: Request):
# Get user info from request (your logic)
user = get_user_info_from_request(request)
result = await client.passkeys.start_registration(
user_id=user.user_id,
email_or_username=user.email,
)
if is_ok(result):
return {"registrationOptions": result.data.registration_options}
else:
print("Error starting registration:", result.error)
raise HTTPException(status_code=500, detail="Failed to start passkey registration")

Next, create a frontend component that triggers the registration. When clicked, it fetches the registrationOptions from your backend and passes them to @simplewebauthn/browser, which handles the device prompt.

import { startRegistration } from '@simplewebauthn/browser';
const EnrollPasskey = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const startResponse = await fetch('/api/passkey/register/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const { registrationOptions } = await startResponse.json();
// Create publicKey using WebAuthn library
const publicKey = await startRegistration({
optionsJSON: registrationOptions,
});
// TODO: Finish Registration Flow
} catch (error) {
console.error('Error creating passkey:', error);
}
};
return (
<div>
<button onClick={handleSubmit}>Enroll a Passkey</button>
</div>
);
};
export default EnrollPasskey;

Clicking the button prompts the browser to create a passkey:

After the user creates the passkey, you need to save it. Add a backend endpoint that accepts the credential and calls Finish Passkey Registration to complete the process.

app.post("/api/passkey/register/finish", async (req, res) => {
// Get user info from request (your logic)
const user = getUserInfoFromRequest(req);
const result = await auth.passkeys.finishRegistration({
userId: user.userId,
publicKey: req.body.publicKey,
});
if (result.ok) {
res.json({ success: true, data: result.data });
} else {
console.error("Error finishing registration:", result.error);
res.status(500).json({ error: "Failed to complete passkey registration" });
}
});
@app.post("/api/passkey/register/finish")
async def passkey_register_finish(request: Request):
# Get user info from request (your logic)
user = get_user_info_from_request(request)
body = await request.json()
result = await client.passkeys.finish_registration(
user_id=user.user_id,
public_key=body["publicKey"],
)
if is_ok(result):
return {"success": True, "data": result.data}
else:
print("Error finishing registration:", result.error)
raise HTTPException(status_code=500, detail="Failed to complete passkey registration")

The last step is updating our frontend to send the credential to this new endpoint. Once successful, users can register passkeys for use during MFA, login, and more.

import { startRegistration } from '@simplewebauthn/browser';
const EnrollPasskey = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Step 1: Get registration options from backend
const startResponse = await fetch('/api/passkey/register/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const { registrationOptions } = await startResponse.json();
// Step 2: Create passkey on user's device
const publicKey = await startRegistration({
optionsJSON: registrationOptions,
});
// Step 3: Send public key to backend to complete registration
const finishResponse = await fetch("/api/passkey/register/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
publicKey: publicKey,
}),
});
if (!finishResponse.ok) {
const error = await finishResponse.json();
throw new Error(error.error || "Failed to finish registration");
}
console.log('Passkey registered successfully!');
} catch (error) {
console.error('Error creating passkey:', error);
}
};
return (
<div>
<button onClick={handleSubmit}>Enroll a Passkey</button>
</div>
);
};
export default EnrollPasskey;

That’s it! Users can now register passkeys with your application. You’ll see registered passkeys in the Dashboard:

A registered passkey in the Dashboard

Now let’s implement passkey authentication.

With registration complete, let’s build the authentication flow. Create a backend endpoint that uses Start Passkey Authentication to generate authenticationOptions for the frontend:

app.post("/api/passkey/authenticate/start", async (req: Request, res: Response) => {
// Get user info from request (your logic)
const user = getUserInfoFromRequest(req);
const result = await auth.passkeys.startAuthentication({
userId: user.userId
});
if (result.ok) {
res.json({ authenticationOptions: result.data.authenticationOptions });
} else {
console.error("Error starting authentication:", result.error);
res.status(500).json({ error: "Failed to start passkey authentication" });
}
});
@app.post("/api/passkey/authenticate/start")
async def passkey_authenticate_start(request: Request):
# Get user info from request (your logic)
user = get_user_info_from_request(request)
result = await client.passkeys.start_authentication(
user_id=user.user_id
)
if is_ok(result):
return {"authenticationOptions": result.data.authentication_options}
else:
print("Error starting authentication:", result.error)
raise HTTPException(status_code=500, detail="Failed to start passkey authentication")

On the frontend, let’s create a button that triggers the passkey authentication flow. First, we’ll request the authenticationOptions from our backend, then pass these to @simplewebauthn/browser which prompts the user to authenticate with their passkey.

import { startAuthentication } from '@simplewebauthn/browser';
const ValidatePasskey = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
const startResponse = await fetch('/api/passkey/authenticate/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const { authenticationOptions } = await startResponse.json();
// Create publicKey using WebAuthn library
const publicKey = await startAuthentication({
optionsJSON: authenticationOptions,
});
// TODO: Finish Validation Flow
} catch (error) {
console.error('Error validating passkey:', error);
}
};
return (
<div>
<button onClick={handleSubmit}>Validate a Passkey</button>
</div>
);
};
export default ValidatePasskey;

Clicking the button prompts the browser to validate a passkey. But we’re not done yet! The next step is to send the resulting signature to the backend and use the Finish Passkey Authentication API to validate it. Let’s add a new endpoint that accepts the credential and finishes the authentication process.

app.post("/api/passkey/authenticate/finish", async (req, res) => {
// Get user info from request (your logic)
const user = getUserInfoFromRequest(req);
const result = await auth.passkeys.finishAuthentication({
userId: user.userId,
publicKey: req.body.publicKey,
});
if (result.ok) {
res.json({ success: true, data: result.data });
} else {
console.error("Error finishing authentication:", result.error);
res.status(500).json({ error: "Failed to complete passkey authentication" });
}
});
@app.post("/api/passkey/authenticate/finish")
async def passkey_authenticate_finish(request: Request):
# Get user info from request (your logic)
user = get_user_info_from_request(request)
body = await request.json()
result = await client.passkeys.finish_authentication(
user_id=user.user_id,
public_key=body["publicKey"],
)
if is_ok(result):
return {"success": True, "data": result.data}
else:
print("Error finishing authentication:", result.error)
raise HTTPException(status_code=500, detail="Failed to complete passkey authentication")

The last step is updating our frontend to send the credential to this new endpoint. If successful, your users can authenticate with passkeys for MFA, login, and more.

import { startAuthentication } from '@simplewebauthn/browser';
const AuthenticateWithPasskey = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
// Step 1: Get authentication challenge from backend
const startResponse = await fetch('/api/passkey/authenticate/start', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
}
});
const { authenticationOptions } = await startResponse.json();
// Step 2: Sign challenge with user's passkey
const publicKey = await startAuthentication({
optionsJSON: authenticationOptions,
});
// Step 3: Send signed challenge to backend for verification
const finishResponse = await fetch("/api/passkey/authenticate/finish", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
publicKey: publicKey,
}),
});
if (!finishResponse.ok) {
const error = await finishResponse.json();
throw new Error(error.error || "Failed to finish authentication");
}
const result = await finishResponse.json();
console.log("Authentication successful:", result);
// Redirect or update UI after successful authentication
} catch (error) {
console.error('Error authenticating with passkey:', error);
}
};
return (
<div>
<button onClick={handleSubmit}>Authenticate with Passkey</button>
</div>
);
};
export default AuthenticateWithPasskey;

And we’re done! We’ve successfully implemented both passkey registration and authentication flows. To learn more about passkey features, check out our overview guide and the complete API in the reference docs.