Passkey Authentication for Self-Hosted Supabase: WebAuthn Setup Guide

Learn how to implement passkey and WebAuthn authentication for your self-hosted Supabase instance using SimpleWebAuthn or third-party providers.

Cover Image for Passkey Authentication for Self-Hosted Supabase: WebAuthn Setup Guide

Passkeys are rapidly becoming the standard for secure, passwordless authentication. By 2026, over 75% of consumers are aware of passkeys, and nearly half of the top 100 websites offer them. Yet if you're running self-hosted Supabase, you'll quickly discover that passkey support isn't built in. Supabase Auth is optimized for traditional password-based authentication, leaving you to implement WebAuthn yourself or integrate a third-party provider.

This guide walks you through the options for adding passkey authentication to your self-hosted Supabase deployment—from rolling your own implementation with SimpleWebAuthn to leveraging managed solutions like Corbado or Descope.

Why Passkeys Matter for Self-Hosted Deployments

Passkeys use public-key cryptography tied to your domain, making them inherently phishing-resistant. Unlike passwords, there's nothing to steal from your database—only the public key is stored server-side. For teams self-hosting Supabase for data residency and compliance reasons, passkeys add another layer of security without the operational burden of password resets and credential stuffing attacks.

The benefits are clear:

  • No password database to protect: Compromise of public keys is useless to attackers
  • Phishing immunity: Passkeys are domain-bound and can't be entered on fake sites
  • Better UX: Users authenticate with biometrics (Face ID, fingerprint) or device PIN
  • Cross-device sync: Modern passkeys sync via iCloud Keychain or Google Password Manager

The catch? Supabase doesn't natively support passkeys for primary authentication. According to GitHub discussions, the team has been exploring WebAuthn as a second factor for MFA, but first-class passkey support isn't on the immediate roadmap.

Your Implementation Options

You have three main paths for adding passkeys to self-hosted Supabase:

Option 1: Roll Your Own with SimpleWebAuthn

The open-source SimpleWebAuthn library handles the heavy lifting of the WebAuthn specification. This approach gives you full control but requires more development effort.

Option 2: Third-Party Passkey Providers

Services like Corbado, Descope, or Passage by 1Password provide drop-in components that handle passkey flows while integrating with Supabase for user storage.

Option 3: Dedicated Libraries

Libraries like Supakeys offer a middle ground—purpose-built for Supabase with less configuration than rolling your own.

Let's explore each approach.

Building Custom Passkey Auth with SimpleWebAuthn

If you want full control over your authentication flow, SimpleWebAuthn makes implementing the WebAuthn specification straightforward. Here's the architecture you'll need to build:

Database Schema

Create a new schema in your Supabase database to store WebAuthn credentials:

-- Create webauthn schema
CREATE SCHEMA IF NOT EXISTS webauthn;

-- Store registered passkeys
CREATE TABLE webauthn.credentials (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  credential_id TEXT NOT NULL UNIQUE,
  public_key BYTEA NOT NULL,
  counter BIGINT NOT NULL DEFAULT 0,
  device_type TEXT,
  backed_up BOOLEAN DEFAULT FALSE,
  transports TEXT[],
  created_at TIMESTAMPTZ DEFAULT NOW(),
  last_used_at TIMESTAMPTZ
);

-- Store temporary challenges for registration/authentication
CREATE TABLE webauthn.challenges (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
  challenge TEXT NOT NULL,
  type TEXT NOT NULL CHECK (type IN ('registration', 'authentication')),
  expires_at TIMESTAMPTZ NOT NULL,
  created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Index for credential lookups
CREATE INDEX idx_credentials_user_id ON webauthn.credentials(user_id);
CREATE INDEX idx_credentials_credential_id ON webauthn.credentials(credential_id);

-- Cleanup expired challenges
CREATE OR REPLACE FUNCTION webauthn.cleanup_expired_challenges()
RETURNS void AS $$
BEGIN
  DELETE FROM webauthn.challenges WHERE expires_at < NOW();
END;
$$ LANGUAGE plpgsql;

Server-Side Implementation

Using SimpleWebAuthn with a Node.js backend (Edge Functions work too):

import {
  generateRegistrationOptions,
  verifyRegistrationResponse,
  generateAuthenticationOptions,
  verifyAuthenticationResponse,
} from '@simplewebauthn/server';
import { createClient } from '@supabase/supabase-js';

const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

const rpName = 'Your App Name';
const rpID = 'yourdomain.com';
const origin = 'https://yourdomain.com';

// Generate registration options for a new passkey
export async function startRegistration(userId: string, userEmail: string) {
  // Get existing credentials for this user
  const { data: existingCredentials } = await supabase
    .from('webauthn.credentials')
    .select('credential_id')
    .eq('user_id', userId);

  const options = await generateRegistrationOptions({
    rpName,
    rpID,
    userID: userId,
    userName: userEmail,
    attestationType: 'none',
    excludeCredentials: existingCredentials?.map(cred => ({
      id: Buffer.from(cred.credential_id, 'base64url'),
      type: 'public-key',
    })),
    authenticatorSelection: {
      residentKey: 'required',
      userVerification: 'required',
    },
  });

  // Store challenge for verification
  await supabase.from('webauthn.challenges').insert({
    user_id: userId,
    challenge: options.challenge,
    type: 'registration',
    expires_at: new Date(Date.now() + 5 * 60 * 1000), // 5 minutes
  });

  return options;
}

The Trade-offs

Building your own implementation gives you:

  • Full control over the authentication flow
  • No external dependencies or third-party services
  • Lower cost at scale (no per-user fees)

But you'll also need to handle:

  • Edge cases like cross-device authentication
  • Browser compatibility quirks
  • Security auditing of your implementation
  • Ongoing maintenance as the WebAuthn spec evolves

For most teams, the third-party approach is more practical unless you have specific requirements or a dedicated security team.

Integrating Corbado for Managed Passkeys

Corbado provides a drop-in web component that handles all passkey flows while storing users in Supabase's native auth.users table. This is particularly useful if you have existing password-based users you want to migrate gradually.

Architecture Overview

The integration works as follows:

  1. Corbado Web Component handles the passkey UI and WebAuthn logic
  2. Your Backend manages session creation and user coordination
  3. Supabase stores user data in auth.users

Database Setup

First, create a PostgreSQL function to look up users by email (Supabase's client doesn't expose this directly):

CREATE OR REPLACE FUNCTION get_user_id_by_email(email TEXT)
RETURNS TABLE (id uuid)
SECURITY definer
AS $$
BEGIN
  RETURN QUERY SELECT au.id FROM auth.users au WHERE au.email = $1;
END;
$$ LANGUAGE plpgsql;

Environment Configuration

CORBADO_PROJECT_ID="your-project-id"
CORBADO_API_SECRET="your-api-secret"
SUPABASE_URL="https://your-project.supabase.co"
SUPABASE_SERVICE_ROLE_KEY="your-service-role-key"
SUPABASE_JWT_SECRET="your-jwt-secret"

Frontend Integration

Embed the Corbado authentication component:

<script defer src="https://your-project-id.frontendapi.corbado.io/auth.js"></script>

<corbado-auth project-id="your-project-id" conditional="yes">
  <input name="username" data-input="username" required />
</corbado-auth>

Webhook Handler for Legacy Users

The key to gradual migration is Corbado's webhook system, which lets existing password-based users authenticate and then prompts them to create a passkey:

import Corbado from '@corbado/node-sdk';
import { createClient } from '@supabase/supabase-js';

const corbado = new Corbado.SDK(process.env.CORBADO_API_SECRET);
const supabase = createClient(
  process.env.SUPABASE_URL!,
  process.env.SUPABASE_SERVICE_ROLE_KEY!
);

export async function handleWebhook(action: string, payload: any) {
  if (action === 'AUTH_METHODS') {
    // Check if user exists in Supabase
    const { data } = await supabase.rpc('get_user_id_by_email', {
      email: payload.email
    });
    return { exists: data && data.length > 0 };
  }

  if (action === 'PASSWORD_VERIFY') {
    // Verify against Supabase Auth
    const { error } = await supabase.auth.signInWithPassword({
      email: payload.email,
      password: payload.password,
    });
    return { verified: !error };
  }
}

This approach lets you migrate users incrementally—existing users can still log in with passwords while being prompted to add a passkey, and new users get passkeys by default.

Alternative: Descope Integration

Descope offers a drag-and-drop flow editor that simplifies passkey implementation. Their approach separates identity (Descope handles authentication) from data (Supabase stores user profiles).

The integration pattern:

  1. User authenticates via Descope's passkey flow
  2. Descope returns a verified identity
  3. Your backend creates or updates the user in Supabase
  4. You issue a Supabase JWT for database access

This clean separation can be advantageous if you want to swap auth providers later, but it does mean users aren't stored in Supabase's auth.users table directly.

Self-Hosted Considerations

When implementing passkeys for self-hosted Supabase, keep these points in mind:

Domain Requirements

WebAuthn credentials are bound to your domain (the "Relying Party ID"). This means:

  • You need a custom domain configured properly
  • SSL is mandatory—passkeys don't work over HTTP
  • Changing domains invalidates all existing passkeys

JWT Configuration

Self-hosted Supabase recently moved to ES256 asymmetric JWT keys. If you're integrating a third-party passkey provider, ensure your JWT secret configuration matches. Check the environment variables guide for details.

Session Management

Unlike password auth where Supabase manages sessions automatically, passkey integrations typically require you to:

  1. Verify the passkey response
  2. Look up or create the user in Supabase
  3. Generate a Supabase JWT manually using the service role key
  4. Return this JWT to the client for subsequent API calls

RLS Compatibility

Your Row Level Security policies should work unchanged as long as you're issuing proper Supabase JWTs with the correct sub claim (user ID).

Choosing the Right Approach

ApproachBest ForEffortCost
SimpleWebAuthnTeams with security expertise, custom requirementsHighLow (self-managed)
CorbadoMigrating existing password users, quick integrationMediumPer-user pricing
DescopeVisual flow builders, enterprise SSO integrationLowPer-user pricing
SupakeysSupabase-first projects, TypeScript codebasesMediumLibrary is free

For most self-hosted deployments, I'd recommend starting with a managed solution like Corbado or Descope. The security implications of rolling your own WebAuthn implementation are significant, and these services handle the edge cases you probably haven't thought of.

If cost is a concern at scale, consider starting with a managed service to validate the user experience, then migrating to a custom SimpleWebAuthn implementation once you've proven the value.

What's Next for Supabase Passkeys?

The Supabase team has indicated they're exploring WebAuthn as a second factor for MFA, which would be a welcome addition. Native passkey support for primary authentication would eliminate the need for these workarounds, but there's no announced timeline.

In the meantime, the integrations described here provide a solid path to passwordless authentication for your self-hosted Supabase deployment.

Further Reading

Ready to simplify your self-hosted Supabase management? Check out Supascale for automated backups, custom domains, and OAuth configuration—all from a single dashboard.