# Add Passkeys to Supabase With Descope

[Kevin Gao](/content/blog/author/kevin-gao/index.html)

Head of AI Strategy and DX

## Introduction
If you’ve built a Next.js app using Supabase and want to modernize your authentication experience, adding **passkeys** with Descope is a great next step.

Passkeys offer a secure, passwordless login method that uses device biometrics, like Face ID, fingerprint scanners, or Yubikeys. Combined with Descope’s seamless authentication flows and Supabase, you can deliver a frictionless and secure login experience with minimal effort.

In this step-by-step tutorial, we'll show you how to quickly integrate Descope into your existing Supabase setup, allowing you to leverage powerful authentication flows and seamless passkey support.

You’ll learn how to:

- Replace a Supabase Auth-based implementation with Descope
- Generate Supabase-compatible JWTs from Descope session JWTs
- Add passkeys to your authentication flow
- Continue using Supabase’s Row Level Security (RLS) with minimal changes

**Read more:** [Developer Guide: How to Implement Passkeys](/content/blog/post/developer-guide-passkeys/index.html)

## Why use passkeys with Descope?
[Supabase](https://supabase.com/) is an open-source alternative to Firebase, offering backend services such as authentication, real-time databases, and serverless functions. Built on Postgres, it provides a scalable and reliable foundation for modern applications.

While Supabase’s authentication options are robust, they don’t include passkeys by default. Additionally, adding passkeys to [Supabase Auth](https://supabase.com/docs/guides/auth) typically requires complex manual setup. Descope offers simple passkey implementation powered by a drag & drop authentication flow editor.

Descope passkeys provide passwordless authentication leveraging built-in device biometrics (e.g., smartphones with facial and fingerprint scanners or [YubiKeys](/content/learn/post/yubikey-authentication/index.html)). This offers two distinct advantages over traditional passwords:

- **Enhanced security:** Passkeys remove the risk of phishing attacks, credential theft, and password reuse.
- **Smooth user experience:** Users authenticate quickly with their device's biometrics—no credentials to remember or manage.

Let’s explore how Descope and Supabase work together to enhance security while keeping the developer experience seamless.

## Integrating Descope passkeys into your Supabase app
The source code for this tutorial is available in the [Descope + Supabase Next.js Sample App repository](https://github.com/descope-sample-apps/supabase-descope-todo-list/tree/main). Follow the README instructions to install the required dependencies.

### How the sample app operates:
1. The Descope Next.js SDK is used in the frontend to manage authentication and create a Descope session JWT.
2. The app generates a Supabase-approved JWT using `jose` and the Supabase JWT secret, embedding the Descope user ID in the token.
3. Supabase decodes the JWT, extracts the `user_id claim`, and uses it to identify the user.
4. The session is managed with Descope in the frontend, and the app creates a Supabase JWT only when a valid session exists.
5. Following this setup, we can leverage Descope-based user details to enforce access control in our application.

## Step 1: Install the Descope Next.js SDK

First, add the Descope SDK to your existing project:

```powershell
npm install @descope/nextjs-sdk
# or
yarn add @descope/nextjs-sdk
```

Next, create a `.env.local` file (if you haven’t already) and add your Descope project credentials:

```powershell
NEXT_PUBLIC_DESCOPE_PROJECT_ID=<your-descope-project-id>
NEXT_PUBLIC_DESCOPE_FLOW_ID=<your-descope-flow-id>
```

You’ll find these values in the [Descope Console](https://app.descope.com/) under Project Settings.

## Step 2: Replace your current auth with Descope
Now replace your existing login logic with the Descope Flow component from the SDK. This will handle all authentication (including passkeys) using your configured Descope Flow.

Here’s an example using `pages/index.tsx`:

```tsx
// pages/index.tsx
import { Descope } from '@descope/nextjs-sdk';
import { useDescope, useSession } from '@descope/nextjs-sdk/client';
import TodoList from '@/components/TodoList';

export default function Home() {
  const { isAuthenticated } = useSession();
  const { logout } = useDescope();

return (
    <>  
      {!isAuthenticated ? (
        <Descope flowId={process.env.NEXT_PUBLIC_DESCOPE_FLOW_ID} />
      ) : (
        <>  
          <TodoList />
          <button onClick={() => logout()}>Logout</button>
        </>
      )}
    </>
  );
}
```

At this point, your app now uses Descope to create authenticated sessions. However, Supabase expects a JWT signed with your project’s secret, so we’ll handle that next.

## Step 3: Generate a Supabase-compatible JWT after Descope login
Once a user logs in with Descope, Supabase still requires a JWT that is signed with your Supabase project's secret. This token must include a valid `sub` claim, which Supabase uses to authorize database access—particularly when using Row Level Security (RLS).

To enable this integration, you will:

1. Extract the `user_id` from the Descope session.
2. Send it to a secure backend route that generates a Supabase-compatible JWT.
3. Initialize the Supabase client on the frontend with the returned token.

### Create the `/api/create-jwt.ts` endpoint
This backend route accepts a Descope `userId`, signs it using your Supabase JWT secret, and returns a token Supabase can validate.

```typescript
// pages/api/create-jwt.ts
import { SignJWT } from 'jose';

export default async function handler(req, res) {
  if (req.method === 'POST') {
    try {
      const { userId } = req.body;
      const supabaseSecret = process.env.SUPABASE_JWT_SECRET;

const token = await new SignJWT({ sub: userId })
        .setProtectedHeader({ alg: 'HS256' })
        .sign(new TextEncoder().encode(supabaseSecret));

res.status(200).json({ token });
    } catch (error) {
      console.error('JWT generation failed:', error);
      res.status(500).json({ error: 'JWT Generation Failed' });
    }
  } else {
    res.setHeader('Allow', ['POST']);
    res.status(405).end(`Method ${req.method} Not Allowed`);
  }
}
```

This ensures your Supabase database will be able to decode the JWT and apply access control logic based on the user's identity.

### Initialize Supabase after Descope login
It is important to call the` /api/create-jwt` endpoint immediately after the Descope authentication flow completes. This is handled using the `onSuccess` callback of the Descope Flow component. After generating the Supabase-compatible JWT, the Supabase client is initialized and passed down as a prop to the TodoList component.

Here is the updated example in `pages/index.tsx`:

```tsx
// pages/index.tsx
import { Descope } from '@descope/nextjs-sdk';
import { useDescope, useSession } from '@descope/nextjs-sdk/client';
import { useState } from 'react';
import { jwtDecode } from 'jwt-decode';
import { createSupabaseClient } from '@/lib/initSupabase';
import TodoList from '@/components/TodoList';
import { SupabaseClient } from '@supabase/supabase-js';
import { Database } from '@/lib/schema';

export default function Home() {
  const { isAuthenticated, sessionToken } = useSession();
  const { logout } = useDescope();

const [supabase, setSupabase] = useState<SupabaseClient<Database> | null>(null);

const handleLoginSuccess = async () => {
    if (!sessionToken) return;

const decoded = jwtDecode<{ sub?: string }>(sessionToken);
    const userId = decoded?.sub;

if (!userId) {
      console.error('Missing user ID in Descope session');
      return;
    }

try {
      const res = await fetch('/api/create-jwt', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId }),
      });

const { token } = await res.json();
      if (token) {
        const client = createSupabaseClient(token);
        setSupabase(client);
      }
    } catch (err) {
      console.error('Failed to generate Supabase JWT:', err);
    }
  };

return (
    <>  
      {!isAuthenticated ? (
        <Descope
          flowId={process.env.NEXT_PUBLIC_DESCOPE_FLOW_ID}
          onSuccess={handleLoginSuccess}
        />
      ) : (
        <>  
          {/* The Supabase client is passed to the TodoList component here */}
          <TodoList supabase={supabase} />
          <button onClick={() => logout()}>Logout</button>
        </>
      )}
    </>
  );
}
```

This pattern ensures that the Supabase client is only initialized after successful authentication and is directly available to components like `TodoList` for securely interacting with the Supabase database using RLS policies.

## Step 4: Add passkeys to your flow
Here’s the exciting part—adding passkey support to your Descope login flow.

- Go to the **Descope Console > Flows**.
- Import the [Passkeys flow template](https://app.descope.com/flows?template=biometrics-socials-template-sign-up-or-in).
- Drag the Passkey Login node into your existing flow as a login option.
- Save and deploy the flow.

And that’s it—your app now supports secure, biometric-based login out of the box. Users can register passkeys for their devices and use them for seamless future logins.

## Continue using Supabase Row Level Security (RLS)
One of Supabase’s biggest strengths is Row Level Security. When you switch to Descope for authentication, you’ll likely need to update your RLS policies and `user_id` types to match the Descope token format.

For example:

- Descope `user_id`s are usually strings (not UUIDs), so your Supabase schema and policies should reflect that.
- You may need to define a Postgres function to extract the `sub` claim from your custom JWT.

Learn how to set up Supabase RLS and integrate Descope from scratch in our [full Supabase + Descope guide](/content/blog/post/nextjs-supabase-descope/index.html).

## Better UX and security without the DIY development
In this guide, we walked through how to modernize your existing Supabase application by integrating Descope for authentication. Integrating Descope passkeys into your Supabase workflow makes your user experience simpler, your app easier to maintain, and your security future-ready.

Descope offers a comprehensive, low / no-code CIAM solution that abstracts complicated coding into a visual editor. Rather than creating auth from scratch, Descope Flows make configuring complex identity processes drag & drop simple.
