โ† Workshops ยท Part 10 ยท Authentication

API Authentication: Supabase, Cognito, and Rolling Your Own

Who issues the token, who verifies it, and what the request carries. Supabase Auth and Amazon Cognito end to end โ€” then three production systems that answered the same question three different ways.

๐Ÿ“ Test yourself โ†“
40 min readAuthenticationRead or present

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.

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.

Supabase

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:

ClaimWhat it actually is
roleThe 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_metadatauser_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

KeyWhere it belongsWhat it does
anon / publishableThe browser. It's meant to be public.Identifies the project. Every query it makes is still filtered by Row Level Security.
service_role / secretServer-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:

SchemeHow you verifyWhere 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:

CheckAttack 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 projectAnyone 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.

Cognito

How Cognito works

Cognito is two unrelated products behind one name, and confusing them wastes a week:

ProductWhat it gives you
User PoolA 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:

โš ๏ธ

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.

TokenContainsMeant for
ID tokenemail, 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 tokenscope, client_id, username, cognito:groups. No custom attributes, no email by default.Calling APIs. This is the OAuth2-correct choice.
Refresh tokenOpaque.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:

๐Ÿšจ

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.

CallerFlowCredential lives in
Browser / mobile userSRP (USER_SRP_AUTH) โ€” the password never crosses the wireThe user's head
Server-side script, test harnessUSER_PASSWORD_AUTH โ€” simpler, must be explicitly enabled on the app clientSecrets Manager / SSM
Another serviceclient_credentials + scopesSecrets 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.

In production

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

CargoNautJunctionNetvia
IssuerSupabase Auth (ES256/RS256)Cognito User Pool (RS256)Its own endpoint (HS256)
Verified byA shared custom Lambda AuthorizerAPI Gateway's native Cognito authorizerPassport, in-process
Identity enrichedAuthorizer โ†’ DB lookup โ†’ contextMiddleware โ†’ OrganizationUserContextvalidate() โ†’ DB lookup
Tenant keyorg_id from your users tablecustom:organization claimagencyId/terminalId/operatorId by role
Roles fromusers.role + app_metadatacognito:groupsusers.role
Other schemesAPI key, all-org M2M key, HMACApiKey, service JWT, webhook tokenNone โ€” one way in
You maintainAn authorizer LambdaA claims middlewarePasswords, reset, verification, refresh, MFA
Cost of a new caller typeOne branch in one LambdaOne branch in the middlewareA 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.

Harden

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
}
RuleWhy
Flat strings onlyAPI 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 tokenThe context is logged. A token in CloudWatch is a credential in CloudWatch, valid until it expires.
Never accept the tenant from the callerExcept 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:

PropertyWhat it means
Opaque and randomNot 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-sideA 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 useEvery refresh issues a new one and invalidates the old. via does this โ€” refresh() returns { accessToken, refreshToken }, not just the access token.
Reuse detectedIf 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:

ApproachEnforced byFails when
Postgres RLS (Supabase)The database โ€” closest to the data, hardest to bypassA 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 throughSomebody writes a raw query that skips it. Catchable in review; catchable in a lint rule.
A WHERE org_id = โ€ฆ in each repositoryDisciplineThe 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

#FailureThe fix
1A 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.
2Signature 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.
3Authorization 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.
4Wrong 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.
5Cache 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

ChooseWhen
Supabase AuthPostgres 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.
CognitoYou'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 ownA 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 themMore 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.

Check yourself

Quiz โ€” 27 questions

Answer every question, then submit to see your score and the correct answers.

Getting authentication right?

JunctionNet builds and reviews production auth layers โ€” Supabase and Cognito integrations, API Gateway authorizers, tenant scoping, and the audit trail that proves who did what. Reach out for help or a team workshop.

โœ‰๏ธ Get in touch โ† Back to workshops