Skip to content

· 5 min read · Nadeera Silva

Web & SaaS

How to Verify a Supabase JWT in Fastify with JWKS

Verify Supabase JWTs in a Fastify API with JWKS and jose, handle the legacy JWT secret, rotate keys safely, and secure Socket.IO connections too.

A golden hexagonal token travels along a teal path toward an arch hung with keys, behind which stands a server with a green light, illustrating JWT verification against a JWKS before reaching the API

Pactroom uses Supabase Auth in the browser and a separate Fastify API on the server. That split raises one question every Supabase project with its own backend has to answer: how do you verify a Supabase JWT on the server? This post shows how I do it with JWKS and the jose library, what to do if your project still uses the legacy JWT secret, and the details that are easy to get wrong.

Why the API must verify the token itself

After sign-in, Supabase Auth gives the browser an access token, which it sends to the API in the Authorization header. It's tempting to decode the token and read the user ID.

The API has to check that the token was signed by your project's key, hasn't expired, and has the issuer and audience you expect. Only then is the user ID trustworthy.

Supabase JWT secret or signing keys: which do you have?

Supabase has two systems, and the right verification depends on which your project uses.

Legacy JWT secretJWT signing keys
AlgorithmHS256 (shared secret)Asymmetric, such as ES256
Who can verifyAnyone holding the secret, or the Auth serverAnyone, with the public keys from JWKS
Supabase's view"No longer recommended. Available for backward compatibility"The recommended system
How to verify on your serverCall GET /auth/v1/userVerify locally against the JWKS

Find it in the dashboard on the JWT signing keys page. If you're still on the legacy secret, Supabase has a Migrate JWT secret button that imports the existing secret into the new system, and their docs say the migration doesn't cause downtime. After that you can add an asymmetric key and rotate to it.

What the Supabase JWKS endpoint is

A JSON Web Key Set is a public document listing the keys used to sign tokens. With signing keys, Supabase serves it at:

https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json

Public keys can only verify signatures, not create tokens, so the API doesn't hold any signing secret. Supabase caches this endpoint at its edge for 10 minutes, which matters during key rotation (more below).

Diagram: the browser signs in with Supabase Auth and sends the access token to the Fastify API, which verifies it against keys cached from the JWKS endpoint, then checks project membership before returning data
Verify the token with public keys, then check permissions. Two separate steps.

Verify a Supabase JWT in Fastify with jose

jose (6.x at the time of writing) handles fetching, caching and matching keys by key ID:

import { createRemoteJWKSet, jwtVerify } from "jose";

const issuer = "https://<project-ref>.supabase.co/auth/v1";
const jwks = createRemoteJWKSet(new URL(`${issuer}/.well-known/jwks.json`));

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, jwks, {
    issuer,
    audience: "authenticated",
    algorithms: ["ES256"], // whatever your signing key uses
  });
  return { userId: payload.sub as string };
}

Then a Fastify hook reads the bearer token and attaches the user. Declaring the request property keeps TypeScript happy:

import type { FastifyRequest, FastifyReply } from "fastify";

declare module "fastify" {
  interface FastifyRequest { user: { userId: string } | null }
}

app.decorateRequest("user", null);

export async function requireUser(request: FastifyRequest, reply: FastifyReply) {
  const header = request.headers.authorization ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return reply.code(401).send({ error: "Unauthorized" });
  try {
    request.user = await verifyAccessToken(token);
  } catch {
    return reply.code(401).send({ error: "Unauthorized" });
  }
}

app.get("/projects/:id", { preHandler: requireUser }, getProject);

Attaching the hook per route, rather than globally, keeps public routes such as health checks open.

Details that matter

Pin the algorithm. algorithms: ["ES256"] means the verifier only accepts what you actually use. Trusting the token header's alg has caused real JWT library vulnerabilities.

Check issuer and audience. Supabase access tokens for signed-in users carry the audience authenticated. Checking both stops tokens from another project, or for another purpose, being accepted.

Create the key set once. createRemoteJWKSet caches keys and refetches when it sees an unknown key ID. Create it at startup, not per request.

Rotate keys in two steps. Add a standby key, wait until every cache has picked it up (Supabase's JWKS edge cache is 10 minutes, plus jose's own cache), then rotate. Supabase's docs warn that rotating before your verifiers know the new key can reject valid tokens.

Authorization is a separate step. A valid token proves who the user is, not that they can open a given project. In Pactroom, project access is checked on the server for project data, separately from token verification, and the project AI relies on the same check before retrieving anything.

Socket.IO needs the same check

Pactroom's chat runs over Socket.IO, and a socket is another way in. Verify the token when the socket connects, using Socket.IO middleware, and check project membership before joining a room:

io.use(async (socket, next) => {
  try {
    const token = socket.handshake.auth.token as string;
    socket.data.user = await verifyAccessToken(token);
    next();
  } catch {
    next(new Error("Unauthorized"));
  }
});

io.on("connection", (socket) => {
  socket.on("join", async (projectId: string) => {
    if (!(await isMember(socket.data.user.userId, projectId))) return;
    socket.join(`project:${projectId}`);
  });
});

Testing it

  • No token, a malformed token and an expired token all return 401.
  • A token with the wrong audience or issuer is rejected.
  • A valid token for a user who isn't a project member gets 403 on project routes.
  • A socket without a valid token can't connect, and can't join another project's room.

These tests are cheap and catch the regressions that matter. In Pactroom they run on every push in the CI/CD pipeline. You can see how it fits the rest of the system in the Pactroom case study.

Frequently asked questions

How do I verify a Supabase JWT on my server?

With JWT signing keys, verify it against your project's JWKS with a library such as jose, checking issuer, audience and algorithm. With the legacy secret, Supabase recommends calling /auth/v1/user.

Where do I find the Supabase JWT secret?

In the dashboard's JWT settings. Supabase marks the legacy secret as no longer recommended and offers a Migrate JWT secret button to move to signing keys.

What is the Supabase JWKS endpoint?

https://<project-ref>.supabase.co/auth/v1/.well-known/jwks.json. It lists the public keys, and Supabase caches it at the edge for 10 minutes.

What audience do Supabase access tokens use?

Tokens for signed-in users carry the audience authenticated.

Can I just decode the JWT to get the user ID?

No. Anyone can create a token with any user ID. Only trust claims from a successful verification.

How do I secure Socket.IO with Supabase auth?

Verify the token in Socket.IO middleware when the socket connects, and check project membership before joining a room.

Sources

  1. JWT Signing Keys (Supabase)
  2. JSON Web Token (JWT) (Supabase)
  3. JWT claims reference (Supabase)
  4. jose (GitHub)
  5. Fastify hooks (Fastify)
  6. Socket.IO middlewares (Socket.IO)
SharePost on XLinkedIn

Comments

Share a question or note about this article.

Loading comments…

Verify Supabase JWT in Fastify: JWKS, jose and Legacy Secret | Nadeera Silva