What you'll learn
Part 11 answered which header and which scheme โ Bearer, cookies, API keys, HMAC. It deliberately stopped short of the harder question: where does the token come from, and who decides it's real? That is this part.
Two managed answers dominate, and they are genuinely different products with different failure modes. Then there's the third answer โ issue your own โ which is more defensible than the internet suggests, and more work than its advocates admit.
- Supabase Auth โ how the token is signed, how to verify it in an API that is not Supabase, why
app_metadataanduser_metadataare not interchangeable, and where Row Level Security fits. - Cognito โ user pools, groups, custom attributes, the ID-token-versus-access-token trap, and machine-to-machine with client credentials.
- Three production systems โ CargoNaut on Supabase behind a shared Lambda Authorizer; JunctionNet on Cognito behind API Gateway's native authorizer; via's transport backend issuing its own JWTs in NestJS. Same problem, three shapes.
- The parts that bite โ the authorizer context contract, refresh-token rotation, authorizer caching, and the five failures that show up in real incidents.
Authentication is three decisions
Every design in this article is a different set of answers to the same three questions. Getting them separate in your head makes the rest straightforward:
1. WHO ISSUES the token? Supabase GoTrue โ Cognito โ your own login endpoint
โ โ owns passwords, MFA, reset, email verification
โผ
2. WHO VERIFIES it? the API gateway โ a custom authorizer โ your app process
โ โ signature, issuer, audience, expiry
โผ
3. WHAT DOES THE REQUEST a verified identity: user id, tenant, role/scopes
CARRY ONWARD? โ and NOTHING the caller can influence
The second and third decisions are where systems go wrong. Issuing tokens is a solved problem you can buy. Verifying them correctly on every route, and turning "this token is valid" into "this caller may do this to this tenant's data", is the part you own no matter which provider you pick.
Authentication is not authorization. A verified token says who. It never says may they. Every system below keeps those separate, and the ones that blur them are the ones with cross-tenant bugs. There's a whole section on that further down.
How Supabase Auth works
Supabase Auth (the service is called GoTrue) is a hosted identity provider
bolted onto your Postgres database. Users live in an auth.users
table you don't own. On login it returns two things: a short-lived
access token โ a JWT, about an hour by default โ and a long-lived
refresh token.
A decoded Supabase access token looks like this:
{
"iss": "https://abcdefgh.supabase.co/auth/v1",
"sub": "9f1cโฆ", # the auth user's UUID โ NOT your app user id
"aud": "authenticated",
"exp": 1735689600,
"email": "maria@acme.com",
"role": "authenticated", # a POSTGRES role, not your app role
"app_metadata": { "org_id": "โฆ", "role": "manager" },
"user_metadata": { "display_name": "Maria" }
}
Two things in there are routinely misread, and both are security-relevant:
| Claim | What it actually is |
|---|---|
role | The Postgres role the token maps to โ authenticated, anon or service_role. It is how RLS is evaluated. It is not your application's role, and treating it as one gives every logged-in user the same permissions. |
app_metadata vs user_metadata | user_metadata is writable by the user through the client SDK. app_metadata is writable only with the service-role key. Authorization data goes in app_metadata, always. This distinction is the entire security model of Supabase claims. |
Putting a role in user_metadata is a privilege-escalation bug, not a style issue. The browser SDK's updateUser({ data: {...} }) writes straight into it. If your API reads user_metadata.role, any user can grant themselves admin from the browser console in one line.
Two keys, two very different powers
| Key | Where it belongs | What it does |
|---|---|---|
anon / publishable | The browser. It's meant to be public. | Identifies the project. Every query it makes is still filtered by Row Level Security. |
service_role / secret | Server-side only. Never in a client bundle, never in a mobile app. | Bypasses RLS entirely. It is root on your data. |
Verifying a Supabase token in your own API
If every request went through Supabase's own PostgREST endpoint, you'd never write this code. The moment you put a real API in front โ Lambda, a container, anything โ you become the verifier. Supabase supports two signing schemes and you will meet both:
| Scheme | How you verify | Where it shows up |
|---|---|---|
| ES256 / RS256 (asymmetric, current) | Fetch the project's JWKS at <project>/auth/v1/.well-known/jwks.json and verify with the public key. No secret to distribute. | Browser and user logins on modern projects. |
| HS256 (legacy shared secret) | Verify with the project's JWT secret. Anything holding the secret can also mint tokens. | Older projects; locally minted service and test tokens. |
CargoNaut's authorizer handles both, and the interesting part is what it checks beyond the signature:
def _verify_supabase_jwt(token: str) -> dict:
import jwt
alg = (jwt.get_unverified_header(token).get("alg") or "").upper()
if alg.startswith("HS"):
# Legacy shared secret. Possession of the per-stage secret is the gate.
payload = jwt.decode(
token, os.environ["SUPABASE_JWT_SECRET"], algorithms=["HS256"],
audience="authenticated",
options={"require": ["sub", "exp", "iat"]},
)
else:
from jwt import PyJWKClient
supabase_url = os.environ["SUPABASE_URL"].rstrip("/")
expected_iss = f"{supabase_url}/auth/v1"
# Pin the issuer BEFORE trusting the token: a valid signature from
# somebody else's Supabase project is still somebody else's user.
iss = (jwt.decode(token, options={"verify_signature": False}).get("iss") or "").rstrip("/")
if iss != expected_iss:
raise ValueError(f"token issuer {iss!r} is not this stage's project")
client = _jwks_clients.setdefault(expected_iss, PyJWKClient(f"{expected_iss}/.well-known/jwks.json"))
payload = jwt.decode(
token, client.get_signing_key_from_jwt(token).key,
algorithms=["ES256", "RS256"],
audience="authenticated", issuer=expected_iss,
options={"require": ["sub", "exp", "iat"]},
)
Four defences, each closing a real hole:
| Check | Attack it stops |
|---|---|
Explicit algorithms=[โฆ] | Algorithm confusion โ a token with "alg": "none", or an RS256 public key replayed as an HS256 secret. |
issuer= pinned to this stage's project | Anyone can create a free Supabase project and sign a token with org_id: "your-biggest-customer". The signature is perfectly valid โ it's just from the wrong issuer. |
audience="authenticated" | A token minted for a different purpose being replayed at your API. |
require: [sub, exp, iat] | A token with no expiry. Without require, a missing exp isn't an error โ it just means nothing to check. |
Cache the JWKS client, not the request. PyJWKClient fetches over the network. Build it once per issuer at module scope so warm Lambda invocations reuse it โ otherwise every authorized request pays an HTTPS round trip to Supabase before it does anything useful.
Claims are not your user table
The token's sub is a Supabase auth UUID. Your application has its
own users โ with a tenant, a business role, a status, foreign keys pointing at them. Those
are two different identities, and the join between them belongs in exactly one place.
def _resolve_user_from_sub(sub: str) -> dict | None:
"""Resolve the stable app identity and business role for a JWT subject."""
with _get_db().connect(readonly=True) as session:
row = session.execute(
text("SELECT id, org_id, role::text AS role "
"FROM users WHERE supabase_auth_id = :sub LIMIT 1"),
{"sub": sub},
).fetchone()
return None if row is None else {
"id": str(row.id), "org_id": str(row.org_id), "role": str(row.role),
}
The database row wins over the claim, and that ordering matters. A token issued an hour ago
still carries the role the user had an hour ago. Someone demoted five minutes ago presents a
technically valid token asserting admin. Reading the role from
your own table means a permission change takes effect on the next request instead of the
next login.
The trade is a database query on the authentication path. That's a real cost, and it's why the same lookup should be cached by the authorizer's result cache rather than removed. Skipping it and trusting the claim is defensible for a five-minute token, and indefensible for a one-hour one.
Row Level Security: the other half
Supabase's real distinguishing feature isn't the login screen โ it's that authorization can live in the database. RLS policies are evaluated against the JWT's Postgres role and its claims, so a query from the browser is filtered by the same rules as one from your API:
alter table shipments enable row level security;
-- a user only sees rows in their own org
create policy "shipments are org-scoped" on shipments
for select using (
org_id = ((auth.jwt() -> 'app_metadata') ->> 'org_id')::uuid
);
RLS is opt-in per table, and the default is off. A table created by a migration that forgets enable row level security is readable by anyone holding the public anon key โ which by design is in your JavaScript bundle. Add a test that asserts every table in the public schema has RLS enabled; it is five lines and it will save you once.
If your API connects with the service-role key, RLS is not protecting you. That key bypasses every policy โ which is often the right choice for a trusted backend that does its own scoping, as CargoNaut does. Just be clear which one you picked: a backend on the service-role key with no explicit WHERE org_id = โฆ has no tenant isolation at all.
How Cognito works
Cognito is two unrelated products behind one name, and confusing them wastes a week:
| Product | What it gives you |
|---|---|
| User Pool | A user directory and OIDC identity provider. Sign-up, sign-in, MFA, password reset, groups, custom attributes. Issues JWTs. This is what you want for an API. |
| Identity Pool (Federated Identities) | Exchanges an identity for temporary AWS IAM credentials, so a browser can call S3 or DynamoDB directly. A different job entirely, and not needed for authenticating an API. |
A user pool gives you three levers for carrying authorization data:
- Groups โ arrive as a
cognito:groupsarray claim. This is how JunctionNet models roles:JunctionNetAdmin,OrganizationAdmin,Member,Seller,Customer. - Custom attributes โ
custom:organization,custom:branches,custom:applications. Set at sign-up or by an admin. Immutable once created as a schema field, so name them carefully. - Resource servers and scopes โ OAuth2 scopes for machine callers. More on that below.
Custom attributes are permanent. You cannot rename or delete one, and you cannot change its type or max length after creation. A pool that has been live for two years accumulates custom:org, custom:organisation and custom:organization, all still there. Decide the names before the first deploy.
ID token or access token?
Cognito hands back three tokens and most teams pick the wrong one, for a reason that is genuinely the fault of the design.
| Token | Contains | Meant for |
|---|---|---|
| ID token | email, cognito:username, cognito:groups, and all your custom:* attributes. aud = app client id. | The client, to learn who signed in. OIDC says it is not an API credential. |
| Access token | scope, client_id, username, cognito:groups. No custom attributes, no email by default. | Calling APIs. This is the OAuth2-correct choice. |
| Refresh token | Opaque. | Getting new access tokens. Never sent to your API. |
So the purist answer is "use the access token", and then you discover your tenant lives in
custom:organization, which the access token does not carry. That
is exactly why JunctionNet's client-side helper returns the ID token:
auth_response = self.cognito_client.initiate_auth(
AuthFlow="USER_PASSWORD_AUTH",
AuthParameters={
"USERNAME": config.username,
"PASSWORD": config.password,
"SECRET_HASH": secret_hash, # required when the app client has a secret
},
ClientId=config.client_id,
)
token = auth_response['AuthenticationResult']['IdToken'] # โ the pragmatic choice
return AuthenticationResult(token, "Bearer")
That is a defensible engineering decision, not a mistake โ but it is a decision, so make it deliberately and write down the consequences:
- Your authorizer must pin
audto your app client id and checktoken_use == "id". An ID token is scoped to a client, not to an API, so audience validation is the only thing keeping another app's ID token out. - You inherit the ID token's lifetime and its larger payload in every request header.
- The clean alternative is a pre-token-generation Lambda trigger, which can add custom claims to the access token. If you're starting fresh, do that instead.
Always validate token_use. API Gateway's COGNITO_USER_POOLS authorizer accepts an ID token or an access token from the pool. If your code reads custom:organization and a caller presents an access token, the claim is simply absent โ and a tenant of None that reaches a query builder is how "user sees everything" happens.
Machine-to-machine with client credentials
Human logins and service callers should not share a mechanism. Cognito's answer is a
resource server that declares custom scopes, plus an app client allowed to
use the client_credentials grant โ no user involved:
def authenticate(self, config: AuthConfig) -> AuthenticationResult:
token_url = f"https://{config.domain}/oauth2/token"
encoded = base64.b64encode(f"{config.client_id}:{config.client_secret}".encode()).decode()
response = requests.post(
token_url,
headers={"Authorization": f"Basic {encoded}",
"Content-Type": "application/x-www-form-urlencoded"},
data={"grant_type": "client_credentials"},
timeout=30,
)
response.raise_for_status()
data = response.json()
return AuthenticationResult(data["access_token"], "Bearer",
expires_in=data.get("expires_in"))
The resulting access token carries scope: "documents/read documents/write"
and no sub for a human. Your API authorizes on the scope. This is
strictly better than handing a service a user account and a password, which is the thing it
replaces.
| Caller | Flow | Credential lives in |
|---|---|---|
| Browser / mobile user | SRP (USER_SRP_AUTH) โ the password never crosses the wire | The user's head |
| Server-side script, test harness | USER_PASSWORD_AUTH โ simpler, must be explicitly enabled on the app client | Secrets Manager / SSM |
| Another service | client_credentials + scopes | Secrets Manager / SSM |
Cache the M2M token until just before it expires. Client-credentials tokens are billed per token, and a service that fetches a fresh one on every call turns a fixed cost into a per-request one. Honour expires_in, refresh at ~80% of it, and keep the cache in module scope so warm Lambdas reuse it.
The SECRET_HASH is not optional when the app client was created with a secret: it's base64(HMAC-SHA256(username + client_id, key=client_secret)), and omitting it produces a bare NotAuthorizedException that reads exactly like a wrong password. If you can, create browser-facing app clients without a secret โ a secret in a JavaScript bundle isn't one.
CargoNaut โ Supabase behind one shared Lambda Authorizer
CargoNaut has a dozen SAM services on one HTTP API domain, and three kinds of caller: people in a browser, partners with API keys, and n8n automations posting callbacks. Rather than teach every service about all three, there is one authorizer Lambda, deployed by the platform, whose ARN every service reads from SSM:
Auth:
DefaultAuthorizer: LambdaAuthorizer # deny by default, everywhere
Authorizers:
LambdaAuthorizer:
FunctionArn: !Sub '{{resolve:ssm:/CargoNaut/${Stage}/platform/authorizer_function_arn}}'
AuthorizerPayloadFormatVersion: "2.0"
EnableSimpleResponses: true # {isAuthorized, context} โ no IAM policy
Identity:
Headers: [ Authorization ] # โ also the cache key. Remember this.
ReauthorizeEvery: 300
One function, one handler(), three branches on the scheme:
def handler(event, context):
headers = event.get("headers", {})
route_arn = event.get("routeArn", event.get("methodArn", ""))
auth = headers.get("authorization", "")
if auth.startswith("Bearer "): return _handle_jwt(auth[7:], route_arn)
if auth.startswith("ApiKey "): return _handle_api_key(auth[7:].strip(), event, route_arn)
if headers.get("x-api-key"): return _handle_api_key(headers["x-api-key"], event, route_arn)
if headers.get("x-signature"): return _handle_internal(event, route_arn)
return _deny(route_arn)
Every branch converges on the same output: a resolved org_id, a
principal, a role and a scope list, handed downstream in the authorizer context. Services
never parse a token. They read a struct.
The bodyless-authorizer problem
One constraint shapes the whole design and surprises everyone the first time: API Gateway never sends the request body to a Lambda authorizer โ not in payload format 1.0, not in 2.0. So a cross-org machine credential, which must name the tenant it is acting for, has nowhere to put it except a header:
# An all-org M2M key can write to ANY tenant, so it is restricted to one route
# and must name its target org in a header the authorizer can actually see.
M2M_ALLOWED_ROUTE_SUFFIXES = ("/n8n/tracking-result",)
if result.get("all_org"):
if not any(route_arn.endswith(s) for s in M2M_ALLOWED_ROUTE_SUFFIXES):
return _deny(route_arn) # out-of-scope route for this credential
org_id = (event.get("headers") or {}).get("x-org-id")
if not org_id:
return _deny(route_arn)
return _allow(route_arn, org_id, "m2m-api-key", auth_type="api_key", โฆ)
This is the nuance behind Part 11's "never trust X-Org-Id". The header is trusted here โ but only for a credential that is already authorized for every org, and only on one whitelisted route. The rule isn't "never read the header", it's "the header may never widen what the credential already permits." For a normal org-scoped key the header is ignored entirely, and the org comes from the key.
JunctionNet โ Cognito, the native authorizer, and a context middleware
JunctionNet gets to skip the custom authorizer: API Gateway has a built-in
COGNITO_USER_POOLS authorizer that validates the signature,
issuer, expiry and audience for you, then drops the claims into the event. The application's
job starts after that, in a Powertools middleware that runs before every handler:
def inject_organization_user_context(app, next_middleware):
# 1๏ธโฃ Cognito claims โ the gateway already verified this token.
claims = app.current_event.get("requestContext", {}).get("authorizer", {}).get("claims")
if claims:
user_context = OrganizationUserContext(
organization = claims.get("custom:organization"),
user_id = claims.get("sub"),
username = claims.get("cognito:username"),
user_email = claims.get("email", ""),
user_groups = _normalize_to_list(claims.get("cognito:groups")),
branches = _normalize_to_list(claims.get("custom:branches")),
user_applications = _normalize_to_list(claims.get("custom:applications")),
environment = env_vars.STAGE,
application = env_vars.APPLICATION,
).derive_roles()
derive_roles() is where a Cognito group becomes an application
concept, in one place instead of scattered through handlers:
def derive_roles(self):
groups = set(self.user_groups)
self.is_jnet_admin = "JunctionNetAdmin" in groups
self.is_organization_admin = "OrganizationAdmin" in groups
self.is_organization_member = "Member" in groups
self.is_organization_seller = "Seller" in groups
self.is_organization_customer = "Customer" in groups
return self
The middleware then falls back through a documented priority list for callers that aren't
browser users โ service tokens, static API keys, legacy webhook headers โ each producing the
same OrganizationUserContext. One shape reaches the handler
regardless of how the caller authenticated, which is the same win CargoNaut gets from its
authorizer context.
The dangerous line in this pattern is the fallback. When there is no requestContext.authorizer.claims, the middleware decodes the Bearer token itself โ and for RS256 it uses verify_signature: False, on the stated basis that "the Cognito authorizer in API Gateway is the authoritative validator". That is true only for routes that actually have the authorizer attached. A single route deployed without one turns an unverified decode into a complete authentication bypass: anyone can craft an RS256 token with any custom:organization and it will be believed. If you use this pattern, the unverified path must be impossible to reach โ assert the authorizer is present, or verify the signature properly and accept the JWKS call.
via โ NestJS, self-issued JWTs, guards and refresh tokens
The transport backend has no managed IdP. It owns its users table, hashes passwords with bcrypt, and signs its own tokens. Login is a password check followed by two token issues:
const payload = { email: user.email, sub: user.id, role: user.role, agencyId: user.agencyId };
const accessToken = this.jwtService.sign(payload); // HS256, short-lived
const refreshToken = await this.generateRefreshToken(user.id); // opaque, in the DB
The two tokens are deliberately different kinds of thing. The access token is a signed, stateless JWT. The refresh token is an opaque random string with a row behind it, which is what makes it revocable:
private async generateRefreshToken(userId: string): Promise<string> {
const token = this.generateRandomToken();
const expiresAt = new Date();
expiresAt.setDate(expiresAt.getDate() + 7);
await this.refreshTokenRepository.save(
this.refreshTokenRepository.create({ userId, token, expiresAt }),
);
return token;
}
private generateRandomToken(): string {
// 256 bits of CSPRNG entropy โ unguessable, unlike the old Math.random token.
return randomBytes(32).toString('hex');
}
Verification is a Passport strategy. Note what validate() does
after the signature checks out โ it loads the user again:
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor(โฆ) {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
ignoreExpiration: false, // never true. not even in dev.
secretOrKey: getJwtSecret(),
});
}
async validate(payload: any) {
const user = await this.usersService.findOne(payload.sub);
if (!user) throw new UnauthorizedException('User not found');
// the request's principal comes from the DB, not from the token body
return { id: user.id, email: user.email, role: user.role,
agencyId: user.agencyId, terminalId: user.terminalId ?? null };
}
}
That's the same choice CargoNaut makes in its authorizer, reached independently: a deleted or demoted user stops working on the next request, not at the next token expiry.
Deny by default, opt out explicitly
The guard is registered globally, so a new controller is protected the moment it exists. Only an explicit decorator opens a route:
export class JwtAuthGuard extends AuthGuard('jwt') {
canActivate(context: ExecutionContext) {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
context.getHandler(), context.getClass(),
]);
return isPublic ? true : super.canActivate(context);
}
}
// public.decorator.ts โ the opt-out is one word, and it's greppable
export const Public = () => SetMetadata(IS_PUBLIC_KEY, true);
Fail fast on a missing secret. via's config refuses to boot without JWT_SECRET rather than falling back to a default โ because a committed default secret lets anyone forge a token for any role, and a service that boots is a service nobody investigates. A crash on startup is the cheapest possible failure here.
Side by side
| CargoNaut | JunctionNet | via | |
|---|---|---|---|
| Issuer | Supabase Auth (ES256/RS256) | Cognito User Pool (RS256) | Its own endpoint (HS256) |
| Verified by | A shared custom Lambda Authorizer | API Gateway's native Cognito authorizer | Passport, in-process |
| Identity enriched | Authorizer โ DB lookup โ context | Middleware โ OrganizationUserContext | validate() โ DB lookup |
| Tenant key | org_id from your users table | custom:organization claim | agencyId/terminalId/operatorId by role |
| Roles from | users.role + app_metadata | cognito:groups | users.role |
| Other schemes | API key, all-org M2M key, HMAC | ApiKey, service JWT, webhook token | None โ one way in |
| You maintain | An authorizer Lambda | A claims middleware | Passwords, reset, verification, refresh, MFA |
| Cost of a new caller type | One branch in one Lambda | One branch in the middleware | A new Passport strategy |
Read down the last two rows and the trade is obvious. CargoNaut and JunctionNet bought
passwords, MFA, reset flows and email verification, and pay for it with a verification layer
they had to write and a provider they're coupled to. via owns every one of those flows โ
which is why auth.service.ts is 500 lines containing reset codes,
verification emails and token rotation โ and in exchange has no external dependency in its
login path and complete freedom over its token shape.
All three converged on the same two ideas without coordinating: one verified context object reaches business code regardless of how the caller authenticated, and the database is the source of truth for role and tenant, not the token. If you take two things from this article, take those.
The authorizer contract: what belongs in the context
Whatever verifies the token produces a small, fixed struct. This is the most important interface in the system, because everything downstream trusts it completely:
{
"org_id": "โฆ", # the tenant. non-empty, or the request never got here.
"principal_id": "โฆ", # YOUR user id, not the IdP's subject
"username": "โฆ", # for logs and audit rows
"role": "manager", # from the database, not the claim
"scopes": "a,b,c", # API Gateway context values are STRINGS
"auth_type": "jwt" # jwt | api_key | internal โ audit needs to know
}
| Rule | Why |
|---|---|
| Flat strings only | API Gateway authorizer context values must be strings. A list arrives as "a,b,c" or not at all โ so join on the way in and split on the way out, in one shared helper. |
Include auth_type | "Which of our three credential types did this write come from?" is the first question in every incident. Put it in the context and in the audit row. |
| Never echo the raw token | The context is logged. A token in CloudWatch is a credential in CloudWatch, valid until it expires. |
| Never accept the tenant from the caller | Except the one narrow case above: a credential already authorized for all tenants, on a whitelisted route. |
Authorizer caching is keyed on the identity source โ and nothing else. With Identity.Headers: [Authorization] and ReauthorizeEvery: 300, API Gateway reuses the previous context for five minutes for any request with the same Authorization value. If any other header changes the authorizer's answer โ an x-org-id that selects the tenant, say โ two requests with the same credential and different orgs collide, and the second silently gets the first one's tenant. Either add every deciding header to the identity source, or set ReauthorizeEvery: 0 on those routes. This is a cross-tenant data bug, and it will not show up in any test that runs one request at a time.
Refresh tokens, done properly
Short access tokens are only usable if refreshing is safe. Whether the provider does it or you do, the same four properties apply:
| Property | What it means |
|---|---|
| Opaque and random | Not a JWT. 256 bits from a CSPRNG โ randomBytes(32), never Math.random(), which is seeded predictably and has been the root cause of real account-takeover bugs. |
| Stored server-side | A row you can delete. This is the whole point: it's the only revocation you have, since a signed access token can't be recalled. |
| Rotated on use | Every refresh issues a new one and invalidates the old. via does this โ refresh() returns { accessToken, refreshToken }, not just the access token. |
| Reuse detected | If an already-rotated token is presented again, someone has a copy. Revoke the entire family for that user and force a re-login. This is the step most hand-rolled implementations skip. |
Give expired refresh tokens a cleanup job. A refresh_tokens table with a 7-day expiry and no deletion grows forever and eventually makes login slow. One scheduled delete of expires_at < now() is enough โ and it's exactly the kind of thing a scheduled event is for.
Tenant scoping is not authentication
A verified token tells you the caller is Maria at Acme. It does not stop a handler from returning Beta Corp's shipments. Every system here needed a second, separate layer โ and via's is the most explicit about it, deriving both the query filter and the permission rules from one map:
/**
* SINGLE SOURCE OF TRUTH for row-level tenant scoping.
* Both the CASL ability factory and the ScopeService derive from this map,
* so a role's scope is defined in one place and the two cannot drift.
*/
export const SCOPE_BY_ROLE: Record<UserRole, (p: ScopePrincipal) => ScopeFilter | null> = {
[UserRole.VIA_ADMIN]: () => null, // platform admin: no filter
[UserRole.COMPANY]: (p) => ({ column: 'agency_id', value: p.agencyId! }),
[UserRole.TERMINAL]: (p) => ({ column: 'terminal_id', value: p.terminalId! }),
[UserRole.DRIVER]: (p) => ({ column: 'operator_id', value: p.userId }),
};
// every list query goes through this โ the WHERE clause is not optional
applyScope<T>(qb: SelectQueryBuilder<T>, scope: ScopeContext): SelectQueryBuilder<T> {
const filter = this.resolveFilter(scope);
if (!filter) return qb; // platform admin only
return qb.andWhere(`${qb.alias}.${filter.column} = :p`, { p: filter.value });
}
Three ways to enforce it, and the right answer depends on where your trust boundary is:
| Approach | Enforced by | Fails when |
|---|---|---|
| Postgres RLS (Supabase) | The database โ closest to the data, hardest to bypass | A table ships without RLS enabled, or the backend uses the service-role key. |
| A shared query-scoping service (via) | One code path every query goes through | Somebody writes a raw query that skips it. Catchable in review; catchable in a lint rule. |
A WHERE org_id = โฆ in each repository | Discipline | The one method somebody added in a hurry. This is where cross-tenant leaks actually come from. |
The null-tenant footgun. If resolveFilter returns "no filter" for a platform admin, then any bug that produces an unrecognised role also produces "no filter". Prefer an explicit allow-list of roles that may see everything, and treat an unknown role as deny, not as unrestricted. The same applies to a tenant id that arrives as None and lands in a query builder that quietly drops the clause.
Five failures that actually happen
| # | Failure | The fix |
|---|---|---|
| 1 | A route without the authorizer. One Authorizer: NONE added for a health check, copied to the next endpoint. | Deny by default at the API level. Then a test that enumerates deployed routes and asserts the authorizer is attached to all but a known allow-list. |
| 2 | Signature verified, issuer not. Anyone can stand up a free Supabase project or Cognito pool and sign a perfectly valid token. | Pin issuer and audience to this stage's project/pool. A signature check alone proves the token is real, not that it's yours. |
| 3 | Authorization data in a user-writable claim โ user_metadata, or a custom attribute the user can update. | app_metadata only, or better, your own users table. |
| 4 | Wrong token type. The access token doesn't carry custom:organization, so the tenant reads as None. | Validate token_use explicitly. Reject the type you don't expect instead of coping with a missing claim. |
| 5 | Cache key narrower than the decision. A header that changes the authorizer's answer isn't part of the identity source. | Every deciding input in the identity source, or no caching on those routes. |
Four tests worth more than the rest combined: a request with no credential returns 401 on every route; a token from a different project/pool is rejected; a valid token for tenant A cannot read tenant B; and a user demoted in the database loses access on the next request without re-logging in. Each one has caught a real bug in one of these three systems.
Choosing: managed identity or your own
| Choose | When |
|---|---|
| Supabase Auth | Postgres is already your database, you want RLS doing real work, and you value a login flow that takes an afternoon. Best fit when the frontend talks to the database directly for some things and to your API for others. |
| Cognito | You're on AWS, want the gateway to do verification with no code, need enterprise SAML/OIDC federation, or need OAuth2 client credentials for partners. The developer experience is worse than Supabase's; the AWS integration is better. |
| Your own | A single backend that owns its users, an unusual identity model that doesn't map to either product, or a hard requirement for no external dependency in the login path. Budget for reset, verification, MFA, rotation and lockout โ that's the real cost, not the JWT signing. |
| Two of them | More common than it sounds and usually fine: a managed IdP for humans, your own short-lived signed tokens for service-to-service. Just make sure both land in the same verified context struct. |
Where this connects: the header and scheme choices are Part 11; the API Gateway, Lambda and SAM plumbing is Part 6; the NestJS/container path is Part 7; the user pool, secrets and least-privilege roles live in the platform layer from Part 4; and the verification emails and password-reset mail are Part 9.
An authentication layer you can defend looks like this: one issuer per environment, pinned by issuer and audience; verification that happens before any handler runs, on every route, with no exceptions you can't enumerate; role and tenant read from your database rather than from a claim; one verified context struct that every caller type produces and every handler consumes; and a separate, single-source-of-truth scoping layer that decides what that identity may actually touch.