Implementing JWT Signing Keys with Supabase Auth
Recently, Supabase introduced a new way to handle authentication and authorization called JWT Signing Keys. This approach leverages asymmetric JWTs, giving us the ability to improve both the security and performance of our applications.
In this post, we’ll explore how to use JWT Signing Keys with Next.js + Supabase, and we’ll also take a look under the hood to understand how this mechanism works behind the scenes.
Key core concepts of Supabase Auth and JWTs
Before diving in, let’s quickly review some core concepts about how Supabase Auth works and how JWTs operate.
Auth service
The Auth service is an API maintained by Supabase that manages user authentication for your application. When you deploy a Supabase project, an instance of this service is automatically created alongside it, and the required Auth schema is injected into your database.
Sessions
Supabase Auth gives you fine-grained control over user sessions, which is especially important for security-sensitive applications or those that need to comply with standards like SOC 2, HIPAA, PCI-DSS, or ISO27000. Sessions are created whenever a user signs in and, by default, they last indefinitely while allowing unlimited active sessions across multiple devices.
Each session is represented by two tokens: an access token (a short-lived JWT, typically valid for 5–60 minutes) and a refresh token (a long-lived string that never expires but can only be exchanged once). When the access token expires, the refresh token is used to obtain a new token pair — a process known as refreshing the session. Sessions can end when a user signs out, changes their password, times out due to inactivity, reaches a maximum lifetime, or when another sign-in occurs.
Every access token also contains a session_id claim, a UUID that uniquely identifies the session and can be correlated with the auth.sessions table. When a session is initiated, Supabase stores the record in this table while your app receives the token pair. This design makes it straightforward to track, manage, and secure user sessions while maintaining compliance and stability in your application.
Limiting User Sessions in Supabase
Supabase allows you to control how long a user’s session can last and even enforce a single active session per user. There are three main ways to limit the lifetime of a session:
- Time-boxed sessions → Sessions automatically terminate after a fixed duration, regardless of activity.
- Inactivity timeout → Sessions expire if they are not refreshed within the specified timeout period.
- Single-session enforcement → Ensures that each user can only maintain one active session at a time. When a new session starts, the previous one is invalidated.
Authentication and authorization
Supabase Auth uses JSON Web Tokens (JWTs) for authentication and Row Level Security (RLS) for authorization.
Authentication means checking that a user is who they say they are. Authorization means checking what resources a user is allowed to access.
JWT and Signing Keys
A JWT it’s a string that contain three pieces of information header, payload and signature.
- The header give us the basic information about the our token, the type, the algorithm and optionally the unique key identifier.
- The payload contain identifying information also called
claims - The signature it’s a digital signature using a shared secret or public-key cryptography. The purpose of the signature is to verify the authenticity of the
<header>.<payload>
<header>.<payload>.<signature>
Each part is a string of Base64-URL encoded JSON, or bytes for the signature.
The Problem with Shared Secret Keys
Initially, Supabase was designed to use a single shared secret key to sign all JWTs. While simple, this approach came with several important drawbacks:
- Shared secrets across services: Imagine you have 5 microservices that need to verify JWTs. Each one must store the same secret key. If just one service is compromised and the secret is leaked, all of your services become vulnerable.
- Difficult key rotation: Rotating the secret key immediately invalidates all existing JWTs, forcing users to re-authenticate and potentially disrupting active sessions.
- Dependency on the Auth server: Only the Auth server could safely verify tokens, which added network latency and created a single point of failure if the Auth server went down.
This was the default way Supabase handled authentication before the introduction of JWT Signing Keys.
Understanding JWT Signing Keys
A JWT signing key is based on public-key cryptography (RSA, Elliptic Curves). This approach follows industry best practices and significantly improves the security, reliability, and performance of your applications.
- A private key is used to sign JSON Web Tokens. The signature ensures that the sender of the JWT is authentic and that the payload has not been tampered with.
- A set of public keys is used to verify JWTs. Since these keys can be safely distributed, multiple services can validate tokens without exposing the private key.
This asymmetric design enables key rotation without downtime. Supabase exposes an endpoint containing the set of public keys. When a new key is added, the old one remains valid until all existing JWTs signed with it naturally expire. After that, only the new key is used.
GET https://project-id.supabase.co/auth/v1/.well-known/jwks.json
📌 Note that this is secure as public keys are irreversible and can only be used to verify the signature of JSON Web Tokens, but not create new ones.
This process defines the lifetime of a signing key:

Row Level Security
Row Level Security (RLS) is a PostgreSQL feature that allows us to define policies directly on a table. These policies are evaluated every time the table is accessed, making them a powerful way to enforce fine-grained authorization rules at the database level.
RLS can be applied to SELECT, INSERT, UPDATE, and DELETE operations. For example, here’s a policy that only allows authenticated users to insert a profile into the profiles table:
-- 1. Create table
create table profiles (
id uuid primary key,
user_id uuid references auth.users,
avatar_url text
);
-- 2. Enable RLS
alter table profiles enable row level security;
-- 3. Create Policy
create policy "Users can create a profile."
on profiles for insert
to authenticated -- the Postgres Role (recommended)
with check ( (select auth.uid()) = user_id ); -- the actual Policy
Let's go to the code
First, start by creating a new Next.js project and install the required libraries:
npm install @supabase/supabase-js @supabase/ssr
Next, enable JWT Signing Keys and the new API Keys from the Supabase dashboard.
Finally, add the following environment variables to your .env.local file:
NEXT_PUBLIC_SUPABASE_URL=<your-supabase-project-url>
NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY=<your-publishable-api-key>
We need to create two Supabase clients:
Supabase provides two utilities for Next.js to create database clients optimized for different environments:
client.ts→ usescreateBrowserClient.Intended for code that runs in the browser (React components, UI interactions).
server.ts→ usescreateServerClientwith special cookie handling.Designed for code that runs on the server (Server Components, API Routes, SSR), enabling session persistence and secure access to protected data.
Together, these two clients ensure seamless authentication and secure database access across both the client and server sides of a Next.js application.
// utils/supabase/client.ts
import { createBrowserClient } from "@supabase/ssr";
export function createClient() {
return createBrowserClient(
process.env.NEXT_PUBLIC_SUPABASE_URL || "",
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || ""
);
}
// utils/supabase/server.ts
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
export async function createClient() {
const cookieStore = await cookies();
return createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL || "",
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || "",
{
cookies: {
getAll() {
return cookieStore.getAll();
},
setAll(cookiesToSet) {
try {
cookiesToSet.forEach(({ name, value, options }) => {
cookieStore.set(name, value, options);
});
} catch {
// The `setAll` method was called from a Server Component.
// This can be ignored if you have middleware refreshing
// user sessions.
}
},
},
}
);
}
Now we need to create the middlewares.
The Next.js middleware acts as a “gatekeeper” that runs before every request to check whether the user is authenticated. It automatically refreshes the session using cookies and redirects unauthenticated users to /login, except for public routes such as /login, /auth, /error, and static assets.
This works by intercepting all incoming requests (configured through config.matcher) and calling supabase.auth.getUser() to validate the user session. The middleware also keeps cookies in sync between the client and the server, preventing users from being logged out prematurely.
// middleware.ts
import type { NextRequest } from "next/server";
import { updateSession } from "./src/utils/supabase/middleware";
export async function middleware(request: NextRequest) {
return await updateSession(request);
}
export const config = {
matcher: [
/*
* Match all request paths except for the ones starting with:
* - _next/static (static files)
* - _next/image (image optimization files)
* - favicon.ico (favicon file)
* Feel free to modify this pattern to include more paths.
*/
"/((?!_next/static|_next/image|favicon.ico|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)",
],
};
// utils/supabase/middleware.ts
import { createServerClient } from "@supabase/ssr";
import { type NextRequest, NextResponse } from "next/server";
export async function updateSession(request: NextRequest) {
let supabaseResponse = NextResponse.next({
request,
});
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!,
{
cookies: {
getAll() {
return request.cookies.getAll();
},
setAll(cookiesToSet) {
cookiesToSet.forEach(({ name, value, options }) => {
request.cookies.set(name, value);
});
supabaseResponse = NextResponse.next({
request,
});
cookiesToSet.forEach(({ name, value, options }) => {
supabaseResponse.cookies.set(name, value, options);
});
},
},
}
);
const {
data: { user },
} = await supabase.auth.getUser();
if (
!user &&
!request.nextUrl.pathname.startsWith("/login") &&
!request.nextUrl.pathname.startsWith("/auth") &&
!request.nextUrl.pathname.startsWith("/error")
) {
// no user, potentially respond by redirecting the user to the login page
const url = request.nextUrl.clone();
url.pathname = "/login";
return NextResponse.redirect(url);
}
return supabaseResponse;
}
Now we need to create the login page and its corresponding actions so that users can authenticate.
// login/page.tsx
import { login, signup } from './actions'
export default function LoginPage() {
return (
<form>
<label htmlFor="email">Email:</label>
<input id="email" name="email" type="email" required />
<label htmlFor="password">Password:</label>
<input id="password" name="password" type="password" required />
<button formAction={login}>Log in</button>
<button formAction={signup}>Sign up</button>
</form>
)
}
// login/actions.ts
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { createClient } from "@/src/utils/supabase/server";
export async function login(formData: FormData) {
const supabase = await createClient();
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get("email") as string,
password: formData.get("password") as string,
};
const { error } = await supabase.auth.signInWithPassword(data);
if (error) {
redirect("/error");
}
revalidatePath("/", "layout");
redirect("/");
}
export async function signup(formData: FormData) {
const supabase = await createClient();
// type-casting here for convenience
// in practice, you should validate your inputs
const data = {
email: formData.get("email") as string,
password: formData.get("password") as string,
};
const { error } = await supabase.auth.signUp(data);
if (error) {
redirect("/error");
}
revalidatePath("/", "layout");
redirect("/");
}
With this setup, we now have a simple and functional authentication flow in place.
The end
You can find the complete code here.
Below, I’ve also included the links to the resources I used while putting this guide together.