You must have come across a moment when you're in your banking app, about to transfer a large amount of money, and suddenly it asks you to verify your identity again. Maybe it sends you an SMS code, asks for your password, or prompts for additional security questions. That's step-up authentication in action.
The idea is simple: routine actions get minimal friction, but sensitive operations require extra verification. It's a smart security pattern that balances usability with protection.But traditional step-up methods have problems. SMS codes can be intercepted. Passwords get forgotten or stolen. Security questions are often terrible. Users end up frustrated, and security teams worry about vulnerabilities.
Passkeys offer a better way. They're fast, secure, and users already understand the interaction. Instead of typing codes or remembering passwords, users just touch their fingerprint or look at their phone. The user experience is smooth, and there's nothing for attackers to steal.
What we're building
Let's build a practical example: a money transfer app that uses passkeys for step-up authentication. Here's how it works:
- Small transfers (under $5,000): go through immediately
- Large transfers (over $5,000): require passkey verification
The user experience feels natural. Small transfers are frictionless. Large transfers get one quick biometric check.
Architecture overview
Before we start coding, let's understand how the pieces fit together. We’ll be using Authsignal for our passkey step-auth implementation. The app has four main components:
Passkey enrollment: This is where users register their biometric with your app.
Transaction check: Every time someone initiates a transfer, we evaluate whether it needs extra verification. This is where your business logic lives. Amount thresholds, user risk levels, recipient verification - whatever rules make sense for your application.
Step-Up Challenge: When extra verification is needed, we present the passkey prompt.
Validation: After the user completes the passkey flow, we need to verify on the server that it actually happened. This prevents client-side tampering and gives us an audit trail.
Github repo
All the code for this implementation is available in our GitHub repository. You can clone it, run it locally, and adapt it for your own use case.
This implementation uses Next.js, but no worries if that's not your stack. We have SDKs for Python, Ruby, Go, PHP, and other platforms that follow the same approach with language-specific examples. Head to our docs for examples in your preferred language.
Let’s get to building
First, we need to configure Authsignal in our application.
Install the required packages:
npm install @authsignal/node @authsignal/browser
Set up your configuration:
// lib/authsignal.ts
import { Authsignal } from '@authsignal/node'
export const authsignal = new Authsignal({
apiSecretKey: process.env.AUTHSIGNAL_API_SECRET_KEY,
apiUrl: 'https://api.authsignal.com/v1', // your region api url
})
export const authsignalConfig = {
tenantId: process.env.NEXT_PUBLIC_AUTHSIGNAL_TENANT_ID,
baseUrl: 'https://api.authsignal.com/v1', // your region api url
}
The server-side config handles authentication and API calls to Authsignal. The client-side config allows your frontend to communicate with Authsignal's browser SDK.
Passkey enrollment
Before users can verify high-value transactions, they need to enroll a passkey. This is a one-time setup that registers their biometric authentication method with your application.
The enrollment process has three steps: request permission, create the passkey, and validate success. Let’s build each part
Backend enrollment endpoint:
// api/passkey/enroll/route.ts
export async function POST(request: NextRequest) {
const userId = await getUserId()
// Tell Authsignal we want to start passkey enrollment for this user
const response = await authsignal.track({
userId,
action: 'enroll-passkey',
attributes: { scope: 'add:authenticators' },
})
// Return a token that authorizes the frontend to proceed
return NextResponse.json({ token: response.token })
}
Only generate tokens with the add:authenticators
scope in strongly authenticated contexts. Generating this token from an unauthenticated endpoint creates a security vulnerability where attackers could potentially enroll authenticators for other users.
Frontend enrollment flow:
// components/passkey-manager.tsx
const enrollPasskey = async () => {
try {
// Step 1: Get permission to enroll
const challengeResponse = await fetch('/api/passkey/enroll', {
method: 'POST',
body: JSON.stringify({ userId }),
})
const { token } = await challengeResponse.json()
// Step 2: Create the passkey using browser APIs
const passkeyResponse = await authsignalClient.passkey.signUp({
token,
username: userEmail,
displayName: userEmail. // can skip it, if both userName and displayName are same
})
// Step 3: Validate that enrollment succeeded
if (passkeyResponse.data?.token) {
// Step 3: Validate the passkey creation on server
const validationResponse = await fetch('/api/passkey/validate', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: passkeyResponse.data.token }),
})
if (validationResponse.ok) {
setIsEnrolled(true)
setMessage({ type: 'success', text: 'Passkey successfully enrolled!' })
} else {
throw new Error('Failed to validate passkey')
}
}
} catch (error) {
console.error('Passkey enrollment error:', error)
setMessage({
type: 'error',
text: error instanceof Error ? error.message : 'Failed to enroll passkey'
})
}
}
Here's what happens from the user's perspective: they click "Set up passkey," their device prompts for Touch ID or Face ID, and then they see a success message.
The username
and displayName
parameters help users identify the passkey later if they manage multiple accounts. Use email addresses or usernames that users will recognise.
Transaction verification check
Decide when to require step-up auth based on transfer amount:
// api/passkey/verify-transaction/route.ts
export async function POST(request: NextRequest) {
const userId = await getUserId()
const { amount, recipient } = await request.json()
// Small transfers go through
if (amount <= 5000) {
return NextResponse.json({
requiresVerification: false,
message: 'Transaction approved'
})
}
// Large transfers need verification
const response = await authsignal.track({
userId,
action: 'high-value-transfer',
})
if (response.state === 'CHALLENGE_REQUIRED') {
return NextResponse.json({
requiresVerification: true,
token: response.token,
state: response.state,
})
}
}
Step-up challenge
When verification is needed, prompt for passkey:
// components/passkey-challenge.tsx
const performPasskeyVerification = async () => {
try {
const passkeyResponse = await authsignalClient.passkey.signIn({
action: 'high-value-transfer'
})
if (passkeyResponse.data?.token) {
const validationResponse = await fetch('/api/passkey/validate', {
method: 'POST',
body: JSON.stringify({ token: passkeyResponse.data.token }),
})
const validationData = await validationResponse.json()
if (validationData.isValid) {
onVerificationComplete(true, 'Verification successful')
}
}
} catch (error) {
if (error.message.includes('user_canceled')) {
onVerificationComplete(false, 'Verification cancelled')
} else if (error.message.includes('no_credential')) {
onVerificationComplete(false, 'No passkey found on this device')
}
}
}
The action
parameter connects the server challenge to the client verification.
Validation
Confirm that passkey verification actually happened:
// api/passkey/validate/route.ts
export async function POST(request: NextRequest) {
const { token } = await request.json()
const response = await authsignal.validateChallenge({ token })
return NextResponse.json({
state: response.state,
isValid: response.state === 'CHALLENGE_SUCCEEDED',
userId: response.userId,
})
}
Complete flow
High-value transfer ($7,500):
- User enters transfer details
- App detects high value, calls verification endpoint
- Server responds with challenge token
- App prompts for passkey
- User provides biometric
- App validates token with server
- Transfer proceeds
Low-value transfer ($50):
- User enters transfer details
- App detects low value
- Transfer proceeds immediately
We recommend only enrolling passkeys after users have already enrolled with another authentication factor (like email OTP, SMS, or authenticator app).
Extensions
You can use this pattern for any sensitive action:
- Password changes
- Data exports
- Admin operations
- Account deletions
You can also add risk-based triggers beyond just amount thresholds, like unusual locations or recipient verification.
Wrapping up
Implementing passkey-based step-up authentication might seem complex, but with Authsignal handling the heavy lifting, it's pretty straightforward. The combination of strong security and excellent user experience makes passkeys ideal for protecting high-value transactions.
The key is to start simple, implement basic passkey enrollment and verification, then gradually add sophistication based on your users' needs. With the foundation we've built here, you're well on your way to providing bank-grade security for your most critical operations.
Ready to implement passkeys in your own application? Check out the Authsignal documentation and start building!