Skip to content

Tutorial: OAuth2 with Supabase

This tutorial walks you through adding OAuth2 token validation to your Stoma gateway. We’ll use Supabase as the example provider, but the pattern works with any RFC 7662-compliant introspection endpoint (Auth0, Okta, Keycloak, etc.).

A gateway that:

  1. Accepts requests with a Supabase access token
  2. Validates the token via Supabase’s introspection endpoint
  3. Extracts user info and forwards it to your upstream
  4. Returns 401 if the token is missing or invalid
  • A Stoma project set up (see Quick Start)
  • A Supabase project (free tier works)
  • Node.js 20+ or Cloudflare Workers

In your Supabase dashboard:

  1. Go to Settings → API
  2. Copy your URL (e.g., https://xyzcompany.supabase.co)
  3. Go to Settings → Authentication → Providers
  4. Make sure OAuth providers are configured (or use the anonymous access)

For the introspection endpoint, you’ll use Supabase’s auth REST API:

https://[YOUR_PROJECT].supabase.co/auth/v1/introspect

Here’s a complete gateway that validates OAuth2 tokens:

// OAuth2 token introspection with Supabase (or any OIDC provider).
// Validates bearer tokens via the introspection endpoint, caches
// valid tokens, and forwards user info to upstream services.

import { createGateway, oauth2, cors, requestLog } from "@homegrower-club/stoma";
import { memoryAdapter } from "@homegrower-club/stoma/adapters";

const adapter = memoryAdapter();

const gateway = createGateway({
  name: "protected-api",
  basePath: "/api",
  adapter,

  // Global policies apply to all routes
  policies: [
    requestLog(),
    cors({ origins: ["https://your-app.com"] }),
  ],

  routes: [
    {
      path: "/protected/*",
      pipeline: {
        policies: [
          oauth2({
            // Supabase introspection endpoint — works with any RFC 7662 provider
            introspectionUrl: "https://xyzcompany.supabase.co/auth/v1/introspect",
            clientId: "your-anon-key",
            clientSecret: "your-service-role-key",
            tokenLocation: "header",
            headerName: "Authorization",
            headerPrefix: "Bearer",

            // Forward user info to upstream as headers
            forwardTokenInfo: {
              sub: "x-user-id",
              email: "x-user-email",
              role: "x-user-role",
            },

            // Cache valid tokens for 5 minutes to reduce introspection calls
            cacheTtlSeconds: 300,

            // Require specific scopes
            requiredScopes: ["authenticated"],
          }),
        ],
        upstream: {
          type: "url",
          target: "https://your-backend.internal",
        },
      },
    },
  ],
});

export default gateway;

Let’s break down what each config option does:

OptionWhat it doesWhy it matters
introspectionUrlWhere to validate tokensSupabase’s introspection endpoint
clientId / clientSecretCredentials for the introspection requestAuthenticates your gateway to Supabase
tokenLocationWhere to find the token"header" (default) looks in Authorization
headerPrefixToken format"Bearer" for standard OAuth2 tokens
forwardTokenInfoWhat user data to pass upstreamExtracts claims and sets them as headers
cacheTtlSecondsHow long to remember valid tokensReduces latency and API calls
Terminal window
# Get a token from Supabase (you'd do this in your frontend)
curl -X POST "https://xyzcompany.supabase.co/auth/v1/token?grant_type=password" \
-H "apikey: your-anon-key" \
-H "Content-Type: application/json" \
-d '{"email": "user@example.com", "password": "password123"}'

Then call your gateway:

Terminal window
curl -H "Authorization: Bearer YOUR_ACCESS_TOKEN" \
https://your-gateway.com/api/protected/users
# Your upstream receives:
# x-user-id: the-supabase-user-id
# x-user-email: user@example.com
Terminal window
curl https://your-gateway.com/api/protected/users
# Returns:
# {
# "error": "unauthorized",
# "message": "Missing or invalid OAuth2 token",
# "statusCode": 401
# }

Want to restrict access based on user roles? Add the rbac policy after oauth2:

import { oauth2, rbac } from "@homegrower-club/stoma";
policies: [
oauth2({ /* ... */ }),
rbac({
roleHeader: "x-user-role", // Forwarded from oauth2
roles: ["authenticated"], // Allow any logged-in user
// Or be more specific:
// roles: ["admin", "moderator"],
}),
]

If you prefer validating JWTs directly (no introspection call), use jwtAuth instead:

import { jwtAuth } from "@homegrower-club/stoma";
jwtAuth({
jwksUrl: "https://xyzcompany.supabase.co/auth/v1/.well-known/jwks.json",
issuer: "https://xyzcompany.supabase.co",
audience: "authenticated", // Supabase includes this by default
forwardClaims: {
sub: "x-user-id",
email: "x-user-email",
},
})

When to use which?

ApproachProsCons
OAuth2 introspectionReal-time validation, can revoke tokens instantlyExtra API call on each request (unless cached)
JWT (jwksUrl)No extra API call, fasterCan’t revoke tokens instantly (must wait for expiry)