Supabase Auth’s JWTs are signed, not encrypted, meaning anyone can read their contents, but only those with the secret can create valid ones.

Let’s watch a user sign up and see the JWT in action.

Imagine a new user signs up for your app. Supabase Auth handles this, and upon successful authentication, it issues a JSON Web Token (JWT). This JWT is what your client-side application will use to prove the user’s identity for subsequent API requests. It’s typically sent in the Authorization: Bearer <token> header.

Here’s a simplified look at what a Supabase JWT might contain after a user logs in:

{
  "exp": 1678886400, // Expiration time (Unix timestamp)
  "iat": 1678882800, // Issued at time (Unix timestamp)
  "aud": "YOUR_SUPABASE_URL",
  "sub": "a1b2c3d4-e5f6-7890-1234-567890abcdef", // User's UUID
  "email": "user@example.com",
  "role": "authenticated", // Default role
  "user_metadata": {
    "avatar_url": "http://example.com/avatar.png",
    "full_name": "Jane Doe"
  },
  "app_metadata": {
    // Custom data you might have added
  }
}

This token is signed using your Supabase project’s JWT secret. This signature is crucial: it ensures that the token hasn’t been tampered with. When your backend (or Supabase Edge Functions) receives this token, it can verify the signature using the same secret. If the signature is valid, it knows the token was legitimately issued by Supabase Auth and hasn’t been altered.

Custom Claims: Adding Your Own Data

You’re not limited to the default claims. Supabase Auth allows you to add custom claims to your JWTs. This is incredibly powerful for passing specific user information or application-specific data directly into the token, making it available in your backend logic or Edge Functions.

You can set custom claims in two primary ways:

  1. Via the Supabase Dashboard (User Metadata): For individual users, you can add custom fields to their user_metadata. This is done by navigating to Authentication -> Users, selecting a user, and then editing their User Metadata. For example, you could add a premium_subscriber boolean:

    "user_metadata": {
      "avatar_url": "http://example.com/avatar.png",
      "full_name": "Jane Doe",
      "premium_subscriber": true // Custom claim
    }
    

    When this user logs in, premium_subscriber: true will appear in their JWT.

  2. Via Supabase Edge Functions (Programmatically): For dynamic or application-wide custom claims, you can use Supabase Edge Functions. You can hook into authentication events (like user.signed_in) and programmatically add claims to the JWT.

    Consider an Edge Function that adds a tenant_id if the user belongs to a specific organization.

    // Example Edge Function (auth.user.signed_in hook)
    import { serve } from "https://deno.land/std@0.177.0/http/server.ts";
    
    serve(async (req) => {
      const { type, payload } = await req.json();
    
      if (type === "USER_SIGNED_IN") {
        const user = payload.user;
        let customClaims = {};
    
        // Example: Assign a tenant_id based on email domain
        if (user.email.endsWith("@acme.com")) {
          customClaims.tenant_id = "acme-corp";
        } else {
          customClaims.tenant_id = "default";
        }
    
        // Return the custom claims to be added to the JWT
        return new Response(JSON.stringify({
          // The 'user' object is returned, and Supabase will merge
          // these claims into the existing user object for the JWT.
          user: {
            ...user,
            app_metadata: {
              ...(user.app_metadata || {}), // Preserve existing app_metadata
              ...customClaims
            }
          }
        }));
      }
    
      // For other event types, just pass the payload through
      return new Response(JSON.stringify(payload));
    });
    

    If the user’s email is jane.doe@acme.com, their JWT would then include:

    "app_metadata": {
      "tenant_id": "acme-corp" // Custom claim added by Edge Function
    }
    

The JWT Secret: Your Security Key

The JWT secret is the cryptographic key used to sign and verify the JWTs. It’s the backbone of your authentication security. If this secret is compromised, an attacker could forge valid JWTs for any user, effectively gaining unauthorized access to your application.

  • Where to find it: You can find your JWT secret in your Supabase project settings under API -> JWT Secret. It’s a long, random string.
  • Importance: Never expose this secret publicly. It should only be known by your Supabase Auth instance and your backend services that need to verify JWTs.
  • Rotation: For enhanced security, you can rotate your JWT secret periodically. Supabase allows you to do this, but be aware that this invalidates all existing tokens. You’ll need to ensure your backend services are updated with the new secret before rotating.

Verifying JWTs in Your Backend

When a request comes to your backend (e.g., a Node.js/Express app, a Go API, or a Supabase Edge Function), you’ll need to verify the incoming JWT. This typically involves:

  1. Extracting the token: Get the token from the Authorization: Bearer <token> header.
  2. Decoding and verifying: Use a JWT library (like jsonwebtoken for Node.js) and your Supabase JWT secret to verify the token’s signature and expiration.

Here’s a common pattern in a Node.js Express app:

import jwt from 'jsonwebtoken';

const supabaseUrl = 'https://your-project-ref.supabase.co'; // e.g., 'https://abcxyz.supabase.co'
const JWT_SECRET = process.env.SUPABASE_JWT_SECRET; // Get from environment variables

// Middleware to protect routes
const protect = (req, res, next) => {
  const authHeader = req.headers.authorization;

  if (!authHeader) {
    return res.status(401).json({ message: 'Authorization header missing' });
  }

  const token = authHeader.split(' ')[1]; // 'Bearer <token>'

  if (!token) {
    return res.status(401).json({ message: 'Token missing' });
  }

  try {
    // Verify the token. The audience should match your Supabase URL.
    const decoded = jwt.verify(token, JWT_SECRET, {
      audience: supabaseUrl,
      algorithms: ['HS256'] // Supabase uses HS256 by default
    });

    // Attach the decoded payload to the request object for later use
    req.user = decoded;
    next();
  } catch (error) {
    console.error('JWT Verification Error:', error.message);
    return res.status(401).json({ message: 'Invalid or expired token' });
  }
};

// Example usage in an Express route
app.get('/protected-data', protect, (req, res) => {
  // req.user contains the decoded JWT payload
  const userId = req.user.sub;
  const userRole = req.user.role;
  const tenantId = req.user.app_metadata?.tenant_id; // Access custom claim

  res.json({
    message: `Welcome user ${userId}! Your role is ${userRole}. Tenant: ${tenantId || 'N/A'}.`,
    data: { /* ... your protected data ... */ }
  });
});

In this example, jwt.verify checks the signature against JWT_SECRET, ensures the token hasn’t expired (exp), and confirms the aud (audience) matches your Supabase URL. If all checks pass, req.user will be populated with the token’s payload, including any custom claims you’ve added.

The most surprising thing about Supabase’s JWT implementation is that the role claim is dynamically determined by Supabase’s Row Level Security (RLS) policies, not just a static string you set. When a user logs in, Supabase evaluates the policies associated with their user ID and determines their effective role (e.g., authenticated, user_uuid, or a custom role defined in your auth.roles table). This role is then embedded into the JWT, making it directly usable by your RLS policies for authorization.

Next, you’ll likely want to explore how these custom claims and roles are leveraged within Supabase’s Row Level Security policies to implement granular access control.

Want structured learning?

Take the full Supabase course →