Authsignal secures millions of passkey transactions out of our hosted Sydney region.

Authsignal secures millions of passkey transactions out of our hosted Sydney region.

Join us today!
Blog
/
Current article

How to add passkey step-up auth in your app?

Last Updated:
June 10, 2025
Ashutosh Bhadauriya
How to add passkey step-up auth in your app?
AWS Partner
Authsignal is an AWS-certified partner and has passed the Well-Architected Review Framework (WAFR) for its Cognito integration.
AWS Marketplace

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):

  1. User enters transfer details
  2. App detects high value, calls verification endpoint
  3. Server responds with challenge token
  4. App prompts for passkey
  5. User provides biometric
  6. App validates token with server
  7. Transfer proceeds

Low-value transfer ($50):

  1. User enters transfer details
  2. App detects low value
  3. 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!

Try out our passkey demo
Passkey Demo
Have a question?
Talk to an expert
You might also like
Context-aware MFA: How to protect critical actions without killing UX
Discover how context-aware MFA balances security and user experience by adapting authentication based on real-time risk signals—protecting high-risk actions without frustrating users.
Modern authentication: A strategic edge for forward-thinking financial services institutions
Discover why modernising customer authentication is crucial for financial services institutions in 2025. Cybersecurity expert Vanessa Leite explores the evolving threat landscape, sector-specific challenges, and how innovative solutions like passkeys and adaptive authentication can enhance both security and user experience.
Action & rules: The complete implementation guide
Learn how to implement Authsignal's actions and no-code rules into your app with this complete guide. Includes real code examples, best practices, and advanced tips for risk-based authentication.

Secure your customers’ accounts today with Authsignal.