DEV Community

James Moschou
James Moschou

Posted on • Originally published at jcmosc.com

Adding passkeys to a Supabase app without using third parties

Recently I added passkeys to a minimal Next.js application using Supabase as the
backend.

Supabase Auth makes it incredibly easy to implement user management in your
application. It supports SSO, multi-factor authentication as well as traditional
password authentication out of the box. However, at the time of writing it does
not support signing in with passkeys.

Supabase does make it possible to issue your own JWTs to your users. So
theoretically it is possible to implement a custom authentication method. So I
figured if could implement passkey registration and authentication flows, then I
could simply issue a custom JWT and let the user sign in once their passkey has
been successfully verified.

When I was doing research for this I came across many third-party solutions that
claim to support passkey authentication with Supabase. I was hesitant to use
them, however, as it seems they also want to be the system of record for your
users. I preferred not to go down this route and I was also curious to see how
straightforward it would be to implement passkeys manually. Thankfully there is
an open source library called SimpleWebAuthn that
takes care of most of the logic.

My basic requirements were:

  1. Use Supabase Auth to manage users.

Existing users should still be able to sign in with their password, managed
by Supabase.

  1. Integrate with Supabase Row Level Security.

This means that interacting with Supabase as an authenticated user should use
a user token instead of a service role token.

  1. Enable new and exising users to continue to use passwords or other sign-in methods.

At the moment passkeys are advertised to end users as an optional
convenience. Users should not feel obliged to adopt them. Additionally, it is
important to maintain existing sign-in methods for account recovery flows.

  1. Automatically refresh the user's session.

The custom access token should be automatically refreshed to mirror the
default behaviour of a Supabase-managed session.

Here's how I did it...

Application setup

To create the base application I followed the
official guide
for using Supabase with Next.js. I implemented sign up, sign in, sign out and
reset password flows.

The database schema

We're going to need two new database tables to implement passkeys. One for the
passkey data itself, and another to store the unique challenges.

I created a new schema in the Supabase database called webauthn to act as a
namespace for everything related to the WebAuthn specification.

Note: A quick note on terminology. The word "passkey" is a common noun like
"password". It doesn't refer to any particular specification or data
structure. The specification that defines how user agents (browsers),
authenticators (e.g. Face ID) and relying parties (servers) is called
WebAuthn. The WebAuthn specification describes multiple types of credentials
that can be used to authenticate users. A passkey is a public-key credential
that is discoverable.

First create the schema:

create schema webauthn;
Enter fullscreen mode Exit fullscreen mode

The credentials table will store public key information once they have been
verified. This corresponds to the
Credential Record in the
specification.

create type webauthn.credential_type AS ENUM ('public-key');
create type webauthn.user_verification_status AS ENUM ('unverified', 'verified');
create type webauthn.device_type AS ENUM ('single_device', 'multi_device');
create type webauthn.backup_state AS ENUM ('not_backed_up', 'backed_up');

create table webauthn.credentials (
  id                       uuid not null default gen_random_uuid(),
  user_id                  uuid not null default auth.uid(),
  friendly_name            text,
  credential_type          webauthn.credential_type not null,
  credential_id            varchar not null,
  public_key               bytea not null,
  aaguid                   varchar default '00000000-0000-0000-0000-000000000000'::varchar not null,
  sign_count               integer not null,
  transports               text[] not null,
  user_verification_status webauthn.user_verification_status not null,
  device_type              webauthn.device_type not null,
  backup_state             webauthn.backup_state not null,
  created_at               timestamptz default now() not null,
  updated_at               timestamptz default now() not null,
  last_used_at             timestamptz,
  constraint credentials_pkey primary key (id),
  constraint credentials_credential_id_key unique (credential_id),
  constraint credentials_user_id_fkey foreign key (user_id) references auth.users (id) on delete cascade
);

create unique index credentials_pkey on webauthn.credentials (id uuid_ops);
create unique index credentials_credential_id_key on webauthn.credentials (credential_id text_ops);
Enter fullscreen mode Exit fullscreen mode

The registration and authentication flows both work by issuing a challenge to
the user, which is signed by the authenticator and returned back to the server
to be verified.

The challenges table will store each challenge used to register or
authenticate a passkey.

create table webauthn.challenges (
  id         uuid not null default gen_random_uuid(),
  user_id    uuid null default auth.uid(),
  value      text not null,
  created_at timestamptz not null default now(),
  constraint challenges_pkey primary key (id),
  constraint challenges_value_key unique (value),
  constraint challenges_user_id_fkey foreign key (user_id) references auth.users (id) on delete cascade
);

create unique index challenges_pkey on webauthn.challenges (id uuid_ops);
create unique index challenges_value_key on webauthn.challenges (value text_ops);
Enter fullscreen mode Exit fullscreen mode

The user_id column is nullable, because we will not know who the user is when
they are signing in.

Configuration

The backend will require some configuration variables:

// src/webauthn/config.ts

const relyingPartyID = process.env.WEBAUTHN_RELYING_PARTY_ID
const relyingPartyName = process.env.WEBAUTHN_RELYING_PARTY_NAME
const relyingPartyOrigin = process.env.WEBAUTHN_RELYING_PARTY_ORIGIN

export { relyingPartyID, relyingPartyName, relyingPartyOrigin }
Enter fullscreen mode Exit fullscreen mode

The Relying Party ID should be based on your host's domain name, without the
https:// or port number. For example, example.com or localhost.

Passkey registration overview

As passkeys are an opt-in experience, the first thing to implement is the
ability for an already signed-in user to create a new passkey for themselves.

At a high level, the passkey registration flow looks like this:

  1. The client sends an API request to the server to generate a unique challenge and passkey creation options.
  2. The client creates the passkey on the user's device using the retrieved creation options.
  3. The client sends the authenticator attestation response to the server.
  4. The server verifies the attestation response against the challenge that was generated earlier.
  5. Upon successful verification, the server stores the new credential in the users account.

Start the passkey registration flow

When the user clicks Create Passkey, the application will send a POST request to
the server to obtain the
PublicKeyCredentialCreationOptions
dictionary. This dictionary contains a crypographic challenge, which is
generated on the server.

We need to add a new route handler to the Next.js app. Since this is an
authenticated endpoint, we need to make sure the user is signed in:

// src/app/api/passkeys/challenge/route.ts

import { createClient } from '@/utils/supabase/server'

export async function POST() {
  const supabase = createClient()
  const {
    data: { user }
  } = await supabase.auth.getUser()
  if (!user) {
    return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Now we can create the options dictionary using the
generateRegistrationOptions() function.

import { generateRegistrationOptions } from '@simplewebauthn/server'

const options = await generateRegistrationOptions({
  rpName: relyingPartyName,
  rpID: relyingPartyID,
  userName: user.email,
  userDisplayName: user.user_metadata.display_name,
  attestationType: 'none',
  authenticatorSelection: {
    residentKey: 'preferred',
    userVerification: 'preferred',
    authenticatorAttachment: 'platform'
  }
})
Enter fullscreen mode Exit fullscreen mode

By default, SimpleWebAuthn generates it's own user IDs for privacy. I've opted
to use the existing ID from the auth.users table, as it is already a UUID and
doesn't contain any personally identifying information. You can read more about
how to use custom user IDs
here.

import { isoUint8Array } from '@simplewebauthn/server/helpers'

const options = await generateRegistrationOptions({
  // ...
  userID: isoUint8Array.fromASCIIString(user.id)
})
Enter fullscreen mode Exit fullscreen mode

While the generateRegistrationOptions() takes care of generating the
cryptographic challenge, we still have to save it so we can retrieve it later in
the verify route handler.

import { saveWebAuthnChallenge } from '@/webauthn/store'

const challenge = await saveWebAuthnChallenge({
  user_id: user.id,
  value: options.challenge
})
Enter fullscreen mode Exit fullscreen mode

Finally, we return the options dictionary to the browser:

return NextResponse.json(options, { status: 200 })
Enter fullscreen mode Exit fullscreen mode

We can improve this to prevent the user from re-registering the same credential
twice. All we have to do is pass any existing credentials in the
excludeCredentials property when calling generateRegistrationOptions():

import { listWebAuthnCredentialsForUser } from '@/webauthn/store'

const credentials = await listWebAuthnCredentialsForUser(user.id)

const options = await generateRegistrationOptions({
  // ...
  excludeCredentials: credentials.map((credential) => ({
    id: credential.credential_id,
    type: credential.credential_type,
    transports: credential.transports
  }))
})
Enter fullscreen mode Exit fullscreen mode

Complete the passkey registration flow

Once the user has created a new passkey on their device, the browser will call
our verify endpoint to register the passkey.

The first thing to do is to retrieve the challenge that was created earlier.
Since the user is already authenticated during this flow, we can look the
challenge up by its user_id field:

// src/app/api/passkeys/verify/route.ts

import { getWebAuthnChallengeByUser } from '@/webauthn/store'

export async function POST(request: NextRequest) {
  const supabase = createClient()
  const {
    data: { user }
  } = await supabase.auth.getUser()
  if (!user) {
    return NextResponse.json({ error: 'Not authenticated' }, { status: 401 })
  }

  const challenge = await getWebAuthnChallengeByUser(user.id)

  // ...
}
Enter fullscreen mode Exit fullscreen mode

Challenges should only be valid for one attempt in order to prevent replay
attacks. Once the challenged has been retrieved, immediately delete it from the
database regardless of whether it will be sucessfully verified or not:

import { deleteWebAuthnChallenge } from '@/webauthn/store'

await deleteWebAuthnChallenge(challenge.id)
Enter fullscreen mode Exit fullscreen mode

Now we can call the verifyRegistrationResponse() function with the attestation
response received from the client and the expected challenge:

import { verifyRegistrationResponse } from '@simplewebauthn/server'
import { relyingPartyID, relyingPartyOrigin } from '@/webauthn/config'

const data = await request.json()
const verification = await verifyRegistrationResponse({
  response: data,
  expectedChallenge: challenge.value,
  expectedOrigin: relyingPartyOrigin,
  expectedRPID: relyingPartyID
})

const { verified } = verification
if (!verified) {
  return NextResponse.json(
    { error: 'Could not verify passkey' },
    { status: 401 }
  )
}
Enter fullscreen mode Exit fullscreen mode

If the credential was verified successfully we can store it in our database:

import { saveWebAuthnCredential } from '@/webauthn/store'

const { registrationInfo } = verification

const values = {
  user_id: user.id,
  friendly_name: `Passkey created ${new Date().toLocaleString()}`,

  credential_type: registrationInfo.credentialType,
  credential_id: registrationInfo.credentialID,

  public_key: registrationInfo.credentialPublicKey,
  aaguid: registrationInfo.aaguid,
  sign_count: registrationInfo.counter,

  transports: data.response.transports ?? [],
  user_verification_status: registrationInfo.userVerified
    ? 'verified'
    : 'unverified',
  device_type:
    registrationInfo.credentialDeviceType === 'singleDevice'
      ? 'single_device'
      : 'multi_device',
  backup_state: registrationInfo.credentialBackedUp
    ? 'backed_up'
    : 'not_backed_up'
}

const savedCredential = await saveWebAuthnCredential(values)
Enter fullscreen mode Exit fullscreen mode

Finally, we can return some data to the browser so it can update the user
interface of the Settings page.

const passkeyDisplayData = {
  credential_id: savedCredential.credential_id,
  friendly_name: savedCredential.friendly_name,

  credential_type: savedCredential.credential_type,
  device_type: savedCredential.device_type,
  backup_state: savedCredential.backup_state,

  created_at: savedCredential.created_at,
  updated_at: savedCredential.updated_at,
  last_used_at: savedCredential.last_used_at
}

return NextResponse.json(passkeyDisplayData, {
  status: 201,
  headers: {
    Location: `/api/passkeys/${savedCredential.id}`
  }
})
Enter fullscreen mode Exit fullscreen mode

The registration user interface

The Settings page contains a typical data table to show existing passkeys and a
Create Passkey button.

The page route is a server component that fetches whatever passkeys already
exist for the user and passes them to a client component:

// src/app/dashboard/settings/security/page.tsx

import { createClient } from '@/supabase/server'
import { listWebAuthnCredentialsForUser } from '@/webauthn/store'
import { redirect } from 'next/navigation'
import { PasskeysSection } from './passkeys-section'

export default async function SecuritySettingsPage() {
  const supabase = createClient()
  const {
    data: { user }
  } = await supabase.auth.getUser()
  if (!user) {
    redirect('/signin')
  }

  const credentials = await listWebAuthnCredentialsForUser(user.id)
  const passkeys = credentials.map((credential) => ({
    credential_id: credential.credential_id,
    friendly_name: credential.friendly_name,
    credential_type: credential.credential_type,
    device_type: credential.device_type,
    backup_state: credential.backup_state,
    created_at: credential.created_at,
    updated_at: credential.updated_at,
    last_used_at: credential.last_used_at
  }))

  return <PasskeysSection initialPasskeys={passkeys} />
}
Enter fullscreen mode Exit fullscreen mode

Define a client function to perform the end-to-end registration flow. Calling
this function will either return the new passkey if it's successful or throw an
error:

// app/webauthn/client.ts

import { startRegistration } from '@simplewebauthn/client'
import { sendPOSTRequest } from './helpers'

export async function createPasskey() {
  const options = await sendPOSTRequest('/api/passkeys/challenge')
  const credential = await startRegistration(options)
  const newPasskey = await sendPOSTRequest('/api/passkeys/verify', credential)
  if (!newPasskey) {
    throw new Error('No passkey returned from server')
  }
  return newPasskey
}
Enter fullscreen mode Exit fullscreen mode

This function will be called from the onClick handler of the Create Passkey
button:

// src/app/dashboard/settings/security/passkeys-section.tsx

import { useState } from 'react'
import { toast } from 'sonner'
import { createPasskey } from '@/webauthn/client'

export default function PasskeysSection({
  initialPasskeys
}: {
  initialPasskeys: {
    // ...
  }[]
}) {
  const [passkeys, setPasskeys] = useState(initialPasskeys)
  const [creating, setCreating] = useState(false) // controls loading indicator

  const handleCreatePasskey = async () => {
    try {
      setCreating(true)
      const passkey = await createPasskey()
      setPasskeys((prev) => [...prev, passkey])
    } catch (error) {
      if (error instanceof Error) {
        if (error.name === 'NotAllowedError') {
          // This request has been cancelled by the user.
          return
        }
        toast(error.message)
      }
    } finally {
      setCreating(false)
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

When the button is clicked, the created passkey is optimistically added to the
table's state.

If the user explicitly aborts the flow, a NotAllowedError error will be
thrown. The handler needs to explictly check for this case in order to avoid
showing an error message to the user.

That's it! Now a user can sign in and create their own passkeys.

Passkey authentication overview

The flow for authenticating with a passkey is quite similar to the registration
flow. We'll need two endpoints to start and complete the flow, as well as some
client-side code to perform the flow.

At a high level, the passkey authentication flow looks like this:

  1. The client sends an API request to the server to generate a unique challenge and passkey authentication options.
  2. The client signs the challenge on the user's device using their passkey.
  3. The client sends the authenticator assertion response to the server.
  4. The server verifies the assertion response against the challenge that was generated earlier.
  5. Upon successful verification, the server generates an access token for the user.

The main difference is that the user will not be authenticated in the endpoint
handlers. The verify endpoint will need to determine who the user is by looking
them up from the matched credential.

This presents a major issue: how do you associate the cryptographic challenge
with the user if you don't know who they are yet? Most passkey tutorials that I
could find on the web assume that your web framework provides some sort of
mutable session object, which you can attach the challenge to. There is no
such session object in Next.js app directory route handlers, so I opted to
implement a simple cookie-based session.

Start the passkey authentication flow

I initially allowed users to sign in with a passkey by adding a Sign In with
Passkey button to the sign-in page. When the user clicks this button the
application will send a POST request to the server to obtain the
PublicKeyCredentialRequestOptions
dictionary.

The route handler will be roughly the same as the start registration route
handler. Since the user will not be authenticated, so there is no need to
validate the supabase user:

// src/app/auth/passkey/route.ts

import { generateAuthenticationOptions } from '@simplewebauthn/server'
import { relyingPartyID } from '@webauthn/config'
import { saveWebAuthnChallenge } from '@webauthn/store'

export async function POST() {
  const options = await generateAuthenticationOptions({
    rpID: relyingPartyID
  })

  const challenge = await saveWebAuthnChallenge({
    value: options.challenge
  })

  // Store the challenge ID in the "session"
  cookies().set('webauthn_state', challenge.id, {
    httpOnly: true,
    sameSite: true,
    secure: !process.env.LOCAL
  })

  return NextResponse.json(options, { status: 200 })
}
Enter fullscreen mode Exit fullscreen mode

Complete the passkey authentication flow

The verify router handler is also similar to its registration counterpart.

First retrieve the challenge using its ID stored in the "session".

// src/app/auth/verify/route.ts

import { getWebAuthnChallenge } from '@/webauth/store'

export async function POST(request: NextRequest) {
  const challengeID = cookies().get('webauthn_state')?.value
  const challenge = await getWebAuthnChallenge(challengeID)
}
Enter fullscreen mode Exit fullscreen mode

Again, we should delete the challenge immediately to prevent replay attacks.

import { removeWebAuthnChallenge } from '@/webauth/store'

await removeWebAuthnChallenge(challengeID)
Enter fullscreen mode Exit fullscreen mode

Next, we can retrieve the credential that was alledgedly used. I say alledgedly
because we haven't verified anything yet.

The request body will contain an id field which will correspond to the
credential_id field of the credential. Be careful! Here the credential_id
field is the one set by the authenticator, which is different to our internal
primary key.

import { getWebAuthnCredentialByCredentialID } from '@/webauth/store'

const data = await request.json()
const credential = await getWebAuthnCredentialByCredentialID(data.id)
if (!credential) {
  return NextResponse.json(
    { error: 'Could not sign in with passkey' },
    { status: 401 }
  )
}
Enter fullscreen mode Exit fullscreen mode

With the expected credential in hand, we can verify the authentication response,
and return the result to the browser.

import { verifyAuthenticationResponse } from '@simplewebauthn/server'
import { relyingPartyID, relyingPartyOrigin } from '@/webauthn/config'

const verification = await verifyAuthenticationResponse({
  response: data,
  expectedChallenge: challenge.value,
  expectedOrigin: relyingPartyOrigin,
  expectedRPID: relyingPartyID,
  authenticator: {
    credentialID: credential.credential_id,
    credentialPublicKey: credential.public_key,
    counter: credential.sign_count,
    transports: credential.transports
  }
})

const { verified } = verification
Enter fullscreen mode Exit fullscreen mode

Before we return the result to the browser, there is some housekeeping we need
to do. Namely, we need to update the sign_count and last_used_at fields on
the credential record in the database:

import { sql } from 'drizzle-orm'

if (verified) {
  await updateWebAuthnCredentialByCredentialID(credential.credential_id, {
    sign_count: verification.authenticationInfo.newCounter,
    last_used_at: sql`now()`
  })
}
Enter fullscreen mode Exit fullscreen mode

The authentication user interface

Define a client function to perform the end-to-end authentication flow. Calling
this function will either return a successful result or throw an error:

// app/webauthn/client.ts

import { sendPOSTRequest } from './helpers'
import { startAuthentication } from '@simplewebauthn/client'

export async function signInWithPasskey(
  useBrowserAutofill?: boolean = false
): Promise<void> {
  const options = await sendPOSTRequest('/auth/passkey')
  const authenticationResponse = await startAuthentication(
    options,
    useBrowserAutofill
  )
  const { verified } = await sendPOSTRequest(
    '/auth/verify',
    authenticationResponse
  )
  if (!verified) {
    throw new Error('Could not sign in with passkey')
  }
  return { verified }
}
Enter fullscreen mode Exit fullscreen mode

This function sould be called from the onClick handler of the Sign In with
Passkey button.

Issue an access token to the user

Currently a user can create and use their own passkey. But nothing happens when
they sign in. That's because we still need to create a new user session.

In order for a user to sign in to Supabase and access data using Row Level
Security, we need to issue our own JWT to the user. Fortunately, Supabase
supports this use case and provides the same JWT signing secret that they use
within the dashboard.

In our application we'll define two new environment variables in order to issue
our own JWTs:

SUPABASE_AUTH_JWT_SECRET=secret_copied_from_supabase_dashboard
SUPABASE_AUTH_JWT_ISSUER=https://exampleapp.com/webauthn
Enter fullscreen mode Exit fullscreen mode

Now we can create a function to build a JWT payload and sign it using our
secret:

// src/webauthn/session.ts

import { User } from '@supabase/supabase-js'
import jwt from 'jsonwebtoken'

const jwtSecret = process.env.SUPABASE_AUTH_JWT_SECRET
const jwtIssuer = process.env.SUPABASE_AUTH_JWT_ISSUER

export function createWebAuthnAccessTokenForUser(user: User) {
  const issuedAt = Math.floor(Date.now() / 1000)
  const expirationTime = issuedAt + 3600 // 1 hour expiry
  const payload = {
    iss: jwtIssuer,
    sub: user.id,
    aud: 'authenticated',
    exp: expirationTime,
    iat: issuedAt,

    email: user.email,
    phone: user.phone,
    app_metadata: user.app_metadata,
    user_metadata: user.user_metadata,
    role: 'authenticated',
    is_anonymous: false
  }

  return jwt.sign(payload, jwtSecret, {
    algorithm: 'HS256',
    header: {
      alg: 'HS256',
      typ: 'JWT'
    }
  })
}
Enter fullscreen mode Exit fullscreen mode

We can deliver this access token to the user agent by calling setSession() on
the supabase client. This will take care of storing it in the appropriate cookie
in the user's browser:

// src/webauthn/session.ts

import { createClient } from '@/supabase/server'

export async function createWebAuthnSessionforUser(user: User) {
  const accessToken = createWebAuthnAccessTokenForUser(user)

  const supabase = createClient()
  const { error } = await supabase.auth.setSession({
    access_token: accessToken,
    refresh_token: '' // dummy value
  })
  if (error) {
    throw error
  }
}
Enter fullscreen mode Exit fullscreen mode

The advantage of calling setSession() is that it will store the custom access
token the same was that Supabase-issued tokens are stored. This should mean that
Supabase clients will work transparently.

A major downside of storing the access token this way is that Supabase clients
will not be able to refresh them, as they do not actually correspond to any
session in the auth.sessions table.

A possible enhancement would be to define our own sessions and
refresh_tokens tables within the webauthn schema, mirroring those in the
auth schema. Then when we initialise the Supabase client, we could supply a
custom accessToken() function that will take care of refreshing the session
for us.

Automatically suggest a passkey friendly name

We can make the experience of creating a new passkey a bit nicer by
automatically suggesting a friendly name instead of "Passkey created {date}".

To do this we'll make use of the Authenticator Attestation GUID, which is
contained in the aaguid field of the attestation object. This value describes
the make and model of the authenticator used to create the passkey. For example,
"iCloud Keychain".

The FIDO alliance maintains a list of metadata statements for known
authenticators. We can use the SimpleWebAuthn library to conveniently look up a
metadata statement by AAGUID.

Unfortunately, when I was testing this on my MacBook, the statement
corresponding to iCloud Keychain was not included. For this we have to go to a
community supported database at
https://github.com/passkeydeveloper/passkey-authenticator-aaguids.

Altogether, our function for retrieving a default friendly name given an AAGUID
looks like this:

// src/webauthn/metadata.ts

import { MetadataService } from '@simplewebauthn/server'
import additionalMetadata from './additional-aaguids.json'

MetadataService.initialize({ verificationMode: 'permissive' }).then(() => {
  console.log('MetadataService initialized')
})

export async function authenticatorDescriptionWithAAGUID(aaguid: string) {
  const statement = await MetadataService.getStatement(aaguid)
  if (statement) {
    return statement.description
  }
  return (additionalMetadata as Record<string, { name: string }>)[aaguid].name
}
Enter fullscreen mode Exit fullscreen mode

Now when registering a new passkey, we can provide a better default friendly
name:

// src/api/passkeys/verify/route.ts

const { registrationInfo } = verification

const description = await
authenticatorDescriptionWithAAGUID(registrationInfo.aaguid)

const friendly_name =
  description ?? `Passkey created ${new Date().toLocaleString()}`
Enter fullscreen mode Exit fullscreen mode

Sign in with Passkey via browser autofill

Another user experience enhancement is to implement
Conditional UI.

This lets us remove the Sign in with Passkey button from the sign-in page and
replace it with an autofill prompt that appears when the user focuses the
username input.

Adopting Conditional UI with the SimpleWebAuthn library is a matter of passing
true for the the useBrowserAutofill argument of the startAuthentication()
function.

// Conditional UI
useEffect(() => {
  let cancelled = false
  setTimeout(() => {
    if (cancelled) {
      return
    }
    signInWithPasskey(email, true /* useBrowserAutofill */)
      .then(() => {
        router.push('/dashboard')
      })
      .catch((error) => {
        if (error instanceof Error) {
          if (error.name === 'NotAllowedError') {
            return
          }
          console.error(error)
          toast(error.message)
        }
      })
  }, 0)
  return () => {
    cancelled = true
  }
}, [])
Enter fullscreen mode Exit fullscreen mode

Warning: React Strict Mode is enabled during development, which causes all
effects to be run twice. I found that calling navigator.credentials.get()
twice on Safari caused the macOS Sign In dialog to become undismissable. To
work around this I wrapped the entire effect inside a timeout and only proceed
if the effect was not cleaned up.

Summary

It's entirely possible to implement passkeys in a Supabase app. The
SimpleWebAuthn library makes it incredibly easy to implement the specification.
Most of the implementation is just wiring it up.

I chose to add passkey registration to the Settings page. A better experience
would be to present an interstitial to the user immediately after they sign in
with a password. The user should also be to able to enter their own friendly
name in order to recognise the passkey later.

Because the application session is implemented using a custom access token,
Supabase cannot automatically refresh it. The next step would be to generate a
refresh token along with the access token and implement session autorefresh.
That is outside the scope of this article though!

Top comments (0)