Skip to content

Tutorial: JWT Authentication

JSON Web Tokens (JWTs) are the standard for API authentication. This tutorial shows you how to validate JWTs in Stoma - using a simple shared secret (HMAC) or public keys (RSA via JWKS).

ApproachUse when
HMAC (shared secret)You control both token signing and validation (e.g., internal services)
JWKS (RSA keys)Tokens are signed by an external identity provider (Auth0, Supabase, Firebase, etc.)

We’ll cover both.

Part 1: HMAC Authentication (Shared Secret)

Section titled “Part 1: HMAC Authentication (Shared Secret)”

This is the simplest approach. You have a secret that both signs and validates tokens.

// JWT authentication using an HMAC shared secret.
// Use this approach when you control both token signing and validation
// (e.g., internal services sharing a secret).
// Demo API: https://stoma.opensource.homegrower.club/demo-api

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

const gateway = createGateway({
  name: "secure-api",
  basePath: "/api",
  policies: [requestLog(), cors()],
  routes: [
    {
      path: "/users/*",
      pipeline: {
        policies: [
          jwtAuth({
            // HMAC shared secret — in production, read from env:
            // secret: process.env.JWT_SECRET
            secret: "my-shared-secret-key",
            headerName: "Authorization",
            tokenPrefix: "Bearer",
            // Forward JWT claims as upstream headers
            forwardClaims: {
              sub: "x-user-id",
              email: "x-user-email",
              role: "x-user-role",
            },
          }),
        ],
        upstream: {
          type: "url",
          target: "https://stoma.opensource.homegrower.club/demo-api",
        },
      },
    },
  ],
});

export default gateway;
Open in Editor

When a request comes in:

  1. Extracts the token from the Authorization: Bearer <token> header
  2. Verifies the signature using the HMAC secret
  3. Checks token expiration (exp claim)
  4. If valid, passes the request to upstream

The example above already includes claim forwarding. The forwardClaims option extracts JWT claims and sets them as upstream headers:

x-user-id: user_123abc
x-user-email: alice@example.com
x-user-role: admin

When your tokens come from an external provider (Auth0, Supabase, Firebase, Okta), you can’t share a secret - instead, you fetch their public keys from a JWKS endpoint.

Your identity provider exposes keys at a well-known URL:

ProviderJWKS URL
Auth0https://YOUR_DOMAIN/.well-known/jwks.json
Supabasehttps://YOUR_PROJECT.supabase.co/auth/v1/.well-known/jwks.json
Firebasehttps://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com
Oktahttps://YOUR_OKTA_DOMAIN/oauth2/default/v1/keys
// JWT authentication using JWKS (RSA public keys) from an external provider.
// Use this approach when tokens are signed by an identity provider like
// Auth0, Supabase, Firebase, or Okta.
// Demo API: https://stoma.opensource.homegrower.club/demo-api

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

const gateway = createGateway({
  name: "secure-api",
  basePath: "/api",
  policies: [requestLog(), cors()],
  routes: [
    {
      path: "/users/*",
      pipeline: {
        policies: [
          jwtAuth({
            // Fetch public keys from the provider's JWKS endpoint
            jwksUrl: "https://your-auth0-domain.auth0.com/.well-known/jwks.json",
            // Validate the issuer claim — prevents tokens from other issuers
            issuer: "https://your-auth0-domain.auth0.com/",
            // Validate the audience claim — prevents tokens for other APIs
            audience: "https://api.yourapp.com",
            // Forward claims as upstream headers
            forwardClaims: {
              sub: "x-user-id",
              email: "x-user-email",
            },
            // Cache JWKS for 5 minutes (default) to reduce network calls
            jwksCacheTtlMs: 300000,
          }),
        ],
        upstream: {
          type: "url",
          target: "https://stoma.opensource.homegrower.club/demo-api",
        },
      },
    },
  ],
});

export default gateway;
Open in Editor
OptionPurpose
jwksUrlWhere to fetch public keys
issuerExpected iss claim - prevents tokens from other issuers
audienceExpected aud claim - prevents tokens for other audiences
forwardClaimsExtract claims and set as upstream headers
jwksCacheTtlMsHow long to cache the JWKS (reduces network calls)

For HMAC (Node.js):

const jwt = require('jsonwebtoken');
const token = jwt.sign(
{ sub: 'user_123', email: 'test@example.com', role: 'admin' },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
console.log(token);
Terminal window
# Without token - should fail
curl https://your-gateway.com/api/users
# {"error":"unauthorized","message":"Missing bearer token","statusCode":401}
# With valid token
curl -H "Authorization: Bearer YOUR_JWT_TOKEN" \
https://your-gateway.com/api/users
# Success! Request proxied to upstream with x-user-id header
ErrorCauseFix
Missing bearer tokenNo Authorization headerAdd header with Bearer <token>
Invalid signatureWrong secret/keyCheck JWT_SECRET or JWKS URL
Token expiredexp claim in pastGet a fresh token
Invalid issueriss claim doesn’t matchCheck issuer config
Invalid audienceaud claim doesn’t matchCheck audience config

Need to accept tokens from multiple providers? Create a custom policy:

import { definePolicy, GatewayError, jwtAuth } from "@homegrower-club/stoma";
const multiAuth = definePolicy({
name: "multi-jwt",
priority: 10, // AUTH priority
handler: async (c, next) => {
const auth0Policy = jwtAuth({
jwksUrl: "https://auth0.auth0.com/.well-known/jwks.json",
issuer: "https://auth0.auth0.com/",
audience: "https://api.example.com",
});
const supabasePolicy = jwtAuth({
jwksUrl: "https://xyz.supabase.co/auth/v1/.well-known/jwks.json",
issuer: "https://xyz.supabase.co",
audience: "authenticated",
});
try {
// Try Auth0 first
await auth0Policy.handler(c, next);
} catch (e) {
// If Auth0 fails, try Supabase
await supabasePolicy.handler(c, next);
}
},
});