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:
- Corbado Web Component handles the passkey UI and WebAuthn logic
- Your Backend manages session creation and user coordination
- 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:
- User authenticates via Descope's passkey flow
- Descope returns a verified identity
- Your backend creates or updates the user in Supabase
- 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:
- Verify the passkey response
- Look up or create the user in Supabase
- Generate a Supabase JWT manually using the service role key
- 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
| Approach | Best For | Effort | Cost |
|---|---|---|---|
| SimpleWebAuthn | Teams with security expertise, custom requirements | High | Low (self-managed) |
| Corbado | Migrating existing password users, quick integration | Medium | Per-user pricing |
| Descope | Visual flow builders, enterprise SSO integration | Low | Per-user pricing |
| Supakeys | Supabase-first projects, TypeScript codebases | Medium | Library 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
- Multi-Factor Authentication for Self-Hosted Supabase
- JWT and Session Security Guide
- Setting Up OAuth Providers
- Auth Providers Documentation
Ready to simplify your self-hosted Supabase management? Check out Supascale for automated backups, custom domains, and OAuth configuration—all from a single dashboard.
