One backend, five identity providers: a walkthrough of auth-reference
I've wired sign-in into a lot of products, with a lot of providers: Firebase Auth, Amazon Cognito, Supabase Auth, Clerk, Auth0, plain OpenID Connect. The client SDKs are all different. The server side is almost the same every time, and it's where the mistakes that matter happen.
So I wrote the server side down once, as code: auth-reference. It's a small TypeScript API that accepts tokens from all of those providers at the same time and hands every route the same user object. The history is built one provider per commit, and each step is a tag you can check out. This post walks through it in that order.
The idea behind the repo
Whatever the provider, what arrives at your API is a JWT signed by an issuer that publishes its public keys as a JWKS. Verifying one always takes the same four steps:
- Read the issuer (
iss) and find the keys for it. - Check the signature with those keys, using the algorithm you chose, not the one in the token header.
- Check the standard claims:
iss,aud,exp,sub. - Check the provider's own rules. This is the only part that really differs.
The repo is laid out around that:
src/
core/
principal.ts the normalised user every route sees
verifier.ts signature + standard claims with jose, then provider checks
registry.ts picks the verifier by issuer: several IdPs on one API
providers/
firebase.ts cognito.ts supabase.ts clerk.ts oidc.ts
server/
app.ts Hono API: /health, /api/me, /api/admin (role-gated)
index.ts wires the providers you configured in .env
test/ one file per provider, offline, with locally signed tokens
web/ React client: one sign-in panel per provider
Step 1: the core, and Firebase
The first commit sets up the three pieces everything else depends on.
The Principal. This is the only user object routes ever see:
export interface Principal {
provider: ProviderName;
issuer: string;
subject: string;
userKey: string; // `${issuer}|${subject}`: what goes in your users table
email?: string;
emailVerified?: boolean;
name?: string;
tenantId?: string; // organisation / tenant, when the provider has one
roles: string[];
claims: JWTPayload; // verified raw claims, for the rare exception
}
userKey is issuer plus subject on purpose. Emails change, get recycled, and two providers can both claim the same address. If a route handler reads cognito:groups or app_metadata directly, the provider has leaked into business logic, and you pay for it the day you add a second one.
The verifier. One function, built on jose, that every provider reuses. It takes the issuer, where the keys come from, the audience, the allowed algorithms, an optional provider-specific check, and a toPrincipal mapping:
export function createJwtVerifier(config: JwtVerifierConfig): Verifier {
const options: JWTVerifyOptions = {
issuer: config.issuer,
algorithms: config.algorithms, // pinned per provider
clockTolerance: config.clockTolerance ?? 5,
requiredClaims: ["sub", "exp", "iat"],
};
if (config.audience !== undefined) options.audience = config.audience;
return {
issuer: config.issuer,
async verify(token) {
let payload: JWTPayload;
try {
({ payload } = await jwtVerify(token, config.keys, options));
} catch (err) {
throw toAuthError(err);
}
config.check?.(payload);
return config.toPrincipal(payload as JWTPayload & { iss: string; sub: string });
},
};
}
keys is either the provider's remote JWKS or a local key set in tests. jose caches the remote one and refetches it when an unknown kid shows up, so key rotation just works.
The registry. This is what lets one API accept several providers safely:
async verify(token: string): Promise<Principal> {
let iss: string | undefined;
try {
iss = decodeJwt(token).iss;
} catch {
throw new AuthError("not a JWT", "malformed_token");
}
const verifier = iss ? this.byIssuer.get(normaliseIssuer(iss)) : undefined;
if (!verifier) throw new AuthError("issuer not accepted", "unknown_issuer");
return verifier.verify(token);
}
The unverified iss only chooses which verifier to use, and that verifier checks the signature against its own keys. A token that claims to come from Firebase but was signed by someone else fails. An issuer the API doesn't know is rejected, never tried against every verifier in turn.
Firebase is the first provider. Its ID tokens are signed by a Google service account, the audience is the project id, and Firebase adds two rules of its own:
check(payload) {
const authTime = payload.auth_time;
if (typeof authTime !== "number" || authTime > Math.floor(Date.now() / 1000) + 5) {
throw new AuthError("auth_time missing or in the future", "invalid_token");
}
if (typeof payload.sub !== "string" || payload.sub.length > 128) {
throw new AuthError("sub must be a string of at most 128 chars", "invalid_token");
}
},
Roles come from a custom claim set with the Admin SDK, and the tenant from firebase.tenant when Identity Platform multi-tenancy is on. One thing this verifier can't do is check revocation: to know that a user signed out everywhere five minutes ago, you need an Admin SDK call on every request. A comment in the code says when to use firebase-admin instead.
The API is small: /health is public, /api/me returns the Principal, /api/admin requires the admin role. When a token is rejected, the API logs the reason code, never the token.
Step 2: a client to try it with
web/ is a Vite + React app with one panel per provider. Each panel signs in with that provider's SDK and calls the same endpoint:
export async function callMe(token: string): Promise<MeResponse> {
const res = await fetch(`${API_URL}/api/me`, { headers: { Authorization: `Bearer ${token}` } });
return { status: res.status, body: await res.json() };
}
That function is the whole contract between client and server. The Firebase panel uses Google sign-in and calls getIdToken() on every request instead of storing the token, because the SDK refreshes it when it's close to expiry. Panels for providers you haven't configured show which env vars they need.
Step 3: Cognito, and two providers at once
Cognito is the first provider where a generic JWT middleware gets it wrong. A user pool issues an ID token and an access token, signed with the same keys. The API has to decide which one it accepts, and the two carry the audience differently: the ID token has aud, the access token has no aud and uses client_id.
audience: config.tokenUse === "id" ? clientIds : undefined,
check(payload) {
if (payload.token_use !== config.tokenUse) {
throw new AuthError(`expected a Cognito ${config.tokenUse} token`, "wrong_token_type");
}
if (config.tokenUse === "access" && !clientIds.includes(String(payload.client_id))) {
throw new AuthError("client_id not allowed", "invalid_token");
}
},
Groups become roles through cognito:groups. A custom:tenant_id attribute becomes the tenant, but it's only present in ID tokens.
On the client, Cognito's managed login is standard OIDC, so the panel uses oidc-client-ts with the authorization code flow and PKCE. The same component comes back in step 6.
This commit also adds api.test.ts, which runs Firebase and Cognito on the same API. One of its tests builds a token that claims the Firebase issuer but is signed with the Cognito key, and checks that it gets a 401.
Step 4: Supabase
Supabase access tokens come from <project>/auth/v1 with the audience authenticated. The verifier supports both signing setups: the newer asymmetric keys, published as a JWKS, and the legacy shared HS256 secret. Supabase has a rule of its own:
check(payload) {
// The anon key is also a JWT from this issuer. It must never count as a user.
if (payload.role !== "authenticated") {
throw new AuthError("not an authenticated user session", "wrong_token_type");
}
},
Roles come only from app_metadata, which only the service role can write. The signed-in user can write user_metadata through updateUser(), so a roles field there would let anyone grant themselves privileges. A test puts superuser in user_metadata and checks that it never shows up in the Principal.
The web panel signs in with a magic link.
Step 5: Clerk
Clerk session tokens have no aud. The thing that ties a token to your front end is azp, the origin it was issued for:
check(payload) {
const azp = payload.azp;
if (typeof azp === "string" && !config.authorizedParties.includes(azp)) {
throw new AuthError("azp not in authorized parties", "invalid_token");
}
},
The active organisation becomes the tenant. The verifier reads the v2 session token format (o.id, o.rol) and falls back to the v1 claims (org_id, org_role). The code also carries two notes:
- The email isn't in the default session token. Add it with a session token customisation rather than calling the Users API on every request.
- The 60-second lifetime is on purpose, because the SDK refreshes the token in the background.
Step 6: any OIDC provider, and SSO
Google, Microsoft Entra ID, Okta, Auth0 and Keycloak all speak OpenID Connect, so one verifier covers them. It reads /.well-known/openid-configuration to find the JWKS. If the discovery document names a different issuer than the one configured, it fails at startup; a trailing slash or a v1/v2 endpoint mix-up would otherwise break every request.
Each IdP puts roles and tenants in a different place, so those are configuration, not code:
| IdP | Roles claim | Tenant claim |
|---|---|---|
| Entra ID | roles |
tid |
| Okta | groups |
|
| Keycloak | realm_access.roles |
|
| Auth0 | a namespaced claim, e.g. https://example.com/roles |
The code that reads these claims handles both nested paths and Auth0-style keys that contain dots.
This step is also where enterprise SSO fits. When a customer needs SAML, the SAML assertion almost never reaches the API itself. A broker consumes it and issues OIDC tokens instead: Entra ID, Okta, Auth0, Cognito's SAML identity providers or Clerk's enterprise connections. For this API, onboarding an SSO customer means one more trusted issuer in the registry plus a tenant mapping, with no change to any route.
Step 7: the cheat sheet
The last commit collects everything above into one table in the README: issuer, keys, audience check, the checks people forget, and where roles and tenants live. It also says when to use each provider's official SDK instead:
firebase-adminfor revocation.aws-jwt-verifyfor Cognito.@clerk/backendfor cookie-based sessions.supabase.auth.getUser()when you need to catch signed-out sessions.
The tests
The tests don't mock the verifiers. They mock the provider, with real cryptography:
export async function fakeIssuer(alg: "RS256" | "ES256" = "RS256"): Promise<FakeIssuer> {
const { publicKey, privateKey } = await generateKeyPair(alg);
const jwk = { ...(await exportJWK(publicKey)), kid: "test-key", alg, use: "sig" };
const keys = createLocalJWKSet({ keys: [jwk] });
return {
keys,
async sign(claims, options = {}) {
return new SignJWT(claims)
.setProtectedHeader({ alg, kid: options.kid ?? "test-key", typ: "JWT" })
.setIssuedAt()
.setExpirationTime(options.expiresIn ?? "5m")
.sign(privateKey);
},
};
}
Each test signs tokens with exactly the claims a provider sends. Some tests also sign tokens a real provider would never hand you:
- an ID token where an access token is expected
- the Supabase anon key
- a Clerk token for another origin
auth_timein the future- a token signed with a key the issuer never published
There are 25 tests. They need no cloud account and run in under half a second:
git clone https://github.com/dukenicols/auth-reference
cd auth-reference && npm install && npm test
To try it against real providers, copy .env.example to .env in the root and in web/, and fill in the providers you have. Then run npm start in the root and npm run dev in web/.
Adding a sixth provider
Adding a provider touches one file in src/providers/, one block in src/server/index.ts, one test file and, optionally, one web panel. No route changes. That's what the structure is for.
The rules the repo follows, which apply whatever the provider:
- Pin the algorithms. Never let the token header choose.
- Unknown issuer means 401.
- Identity is
iss+sub. Never the email, never an id sent in the request body. - Know which token you accept. ID or access, user or anon.
- Check the audience the provider actually uses:
aud,client_idorazp. - Authorise only on claims the user can't write.
- Routes see a
Principal, not provider claims. - Log why a token was rejected, never the token itself.
The code is MIT-licensed. If your stack uses a provider that isn't there yet, it's a good first pull request.
More in Backend, security & infra · All writing