# Supabase Auth implementation guide: designing auth flows, JWT signing keys, sessions, and MFA to production quality

> Take Supabase Auth from 'sign-in works' to 'it survives real attacks and real operations'. Covers choosing an auth method, implementing /auth/confirm correctly with token_hash, migrating from a shared secret (HS256) to asymmetric keys (ES256) with zero downtime plus getClaims, session expiry and the refresh-token reuse interval, enforcing MFA and AAL in RLS, using anonymous sign-ins safely, custom claims, rate limits, and the redirect allowlist — all with official-compliant code.

- Published: 2026-08-16
- Author: 友田 陽大
- Tags: Supabase, 認証・認可, セキュリティ, Next.js, TypeScript, PostgreSQL
- URL: https://tomodahinata.com/en/blog/supabase-auth-production-implementation-guide-flows-jwt-signing-keys-sessions-mfa
- Category: Databases & RLS
- Pillar guide: https://tomodahinata.com/en/blog/supabase-production-guide-nextjs-rls-realtime-edge-functions

## Key points

- The auth method follows from who uses the product. B2C means password plus OAuth, internal and B2B means SSO (SAML), and 'let them try it first' means anonymous sign-in. Each method comes with different abuse paths and rate limits.
- Move JWTs off the shared secret (HS256) and onto asymmetric keys (ES256). The public key is published via JWKS, so getClaims() verifies locally and the round trip disappears. Migration proceeds with zero downtime through standby → in use → previously used → revoked.
- Sessions default to a 1-hour access token; refresh tokens can be used exactly once, with a default reuse interval of 10 seconds. Session time-box and inactivity timeout require the Pro plan or above.
- MFA is expressed by the aal claim in the JWT. Requiring aal2 in an 'as restrictive' RLS policy enforces the second factor in the database, independent of any gap in application code.
- Never use user_metadata for authorization — users can rewrite it themselves. Attach permissions server-side through a custom access token hook.

---

Supabase Auth gets you signed in within ten lines of code. And **those same ten lines, shipped to production, usually leave three holes open**.

1. JWTs are still signed with a shared secret (HS256) — hard to revoke, and every verification costs a round trip to the Auth server
2. Permissions live in `user_metadata` — the one place users can rewrite themselves
3. MFA is enforced by an application `if` statement — one missed path and it's all bypassed

This article is an implementation guide for making Supabase Auth **production-grade**. Faithful to the official documentation as of 2026-08-16, it covers choosing an auth method, migrating to asymmetric keys with zero downtime, designing session lifetimes, enforcing MFA in RLS, using anonymous sign-ins safely, and defending against abuse — with code you can use as-is.

> **What this article does not cover**: wiring into the Next.js App Router (`@supabase/ssr`, `createBrowserClient` / `createServerClient`, cookie getAll/setAll, refreshing tokens in middleware) gets a full article of its own in [Making Supabase RLS actually work in the Next.js App Router](/blog/nextjs-app-router-supabase-rls-ssr-server-client-auth-guide). This article focuses on **the Auth side of the design**.

---

## 1. Choosing an auth method: it follows from who uses the product

Per the official documentation, Supabase Auth provides:

- **Password-based** (email + password)
- **Passwordless** (magic link / one-time password)
- **Social login** (19 providers)
- **Phone auth** (MessageBird / Twilio / Vonage)
- **Enterprise SSO** (SAML 2.0)
- **Multi-factor authentication** (TOTP or phone)
- **Custom providers** (any OAuth2 or OIDC-compatible identity provider)

Plus **anonymous sign-ins**. Choose not by comparing feature matrices but by asking **who uses this**.

| Target users | Default method | Rationale and caveats |
| --- | --- | --- |
| Consumers (B2C) | Password + social | Lowest friction. Always enable password strength settings and leaked-password protection (Pro and above) |
| One-off or infrequent use | Magic link / OTP | Removes password management entirely. The built-in email provider is limited to **2 emails per hour**, so custom SMTP is effectively mandatory |
| Internal systems, B2B | SSO (SAML 2.0) | Moves account lifecycle to the IdP. The only realistic answer when immediate revocation on offboarding is a requirement |
| "Try it before you sign up" products | Anonymous sign-in → upgrade | Lets you show the experience first. Requires abuse controls (chapter 7) |
| Anything handling money or personal data | The above + MFA | Enforce AAL in RLS (chapter 6) |

**The email endpoint limits shape your design.** Per the official rate-limit table, with the built-in email provider `/auth/v1/signup`, `/auth/v1/recover`, and `/auth/v1/user` are capped at **2 emails per hour project-wide**. You will not notice this in development, and you will certainly hit it right after launch. Put configuring custom SMTP (Resend, Amazon SES, etc.) on the pre-launch checklist.

---

## 2. Where user data lives: don't touch the auth schema

Right after sign-in comes "where do I keep the user's profile?" The answer is **your own table in the `public` schema**. The official reasoning:

> Primary keys are **guaranteed not to change**. Columns, indices, constraints or other database objects managed by Supabase **may change at any time**.

In other words, you may reference **only the primary key** of `auth.users` as a foreign key — never other columns or indexes.

```sql
create table public.profiles (
  id uuid not null references auth.users on delete cascade,
  first_name text,
  last_name text,
  primary key (id)
);

alter table public.profiles enable row level security;
```

Create the row on signup with a trigger:

```sql
create function public.handle_new_user()
returns trigger
language plpgsql
security definer set search_path = ''
as $$
begin
  insert into public.profiles (id, first_name, last_name)
  values (
    new.id,
    new.raw_user_meta_data ->> 'first_name',
    new.raw_user_meta_data ->> 'last_name'
  );
  return new;
end;
$$;

create trigger on_auth_user_created
  after insert on auth.users
  for each row execute procedure public.handle_new_user();
```

Write `security definer set search_path = ''` as a unit. Without emptying the search path, a function from an unintended schema can be resolved through it (see [SECURITY DEFINER functions and search_path](/blog/supabase-security-definer-function-search-path-guide)).

### Critical: `user_metadata` cannot be used for authorization

The `raw_user_meta_data` supplied at signup (i.e. `user_metadata`) **can be rewritten by the user via `updateUser()`**. Put `role: "admin"` there and authorize against it, and you have built **an app where anyone can become an administrator**.

Permissions belong in one of:

- **`app_metadata`** — writable only from the server side
- **A dedicated table** (`public.user_roles`, etc.) — managed together with RLS

Getting them into the JWT is the job of the custom access token hook in chapter 5.

---

## 3. Getting email auth right: `token_hash` and `/auth/confirm`

Magic links and email confirmation have a signature failure mode: get one place wrong and **clicking the link doesn't sign you in**. The correct shape is this.

First, change the email template to use `{{ .TokenHash }}`.

```html
<h2>Sign in to your account</h2>

<p>Use this link to sign in to your account:</p>
<p><a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email">Sign in</a></p>
```

The send side uses `signInWithOtp`:

```ts
const { data, error } = await supabase.auth.signInWithOtp({
  email: "valid.email@supabase.io",
  options: {
    // false if you don't want unknown addresses to silently create users
    shouldCreateUser: false,
    emailRedirectTo: "https://example.com/welcome",
  },
});
```

The receiving `/auth/confirm` exchanges the hash for a session via `verifyOtp`:

```ts
// app/auth/confirm/route.ts (Next.js App Router)
import { type EmailOtpType } from "@supabase/supabase-js";
import { NextResponse, type NextRequest } from "next/server";
import { createClient } from "@/lib/supabase/server";

export async function GET(request: NextRequest) {
  const { searchParams, origin } = new URL(request.url);
  const tokenHash = searchParams.get("token_hash");
  const type = searchParams.get("type") as EmailOtpType | null;
  // Open-redirect defense: never accept an external URL
  const nextParam = searchParams.get("next") ?? "/";
  const next = nextParam.startsWith("/") && !nextParam.startsWith("//") ? nextParam : "/";

  if (!tokenHash || !type) {
    return NextResponse.redirect(new URL("/auth/error?reason=missing_token", origin));
  }

  const supabase = await createClient();
  const { error } = await supabase.auth.verifyOtp({ token_hash: tokenHash, type });

  if (error) {
    // Don't put error detail in the URL (it becomes a user-enumeration / leak path)
    return NextResponse.redirect(new URL("/auth/error?reason=invalid_token", origin));
  }

  return NextResponse.redirect(new URL(next, origin));
}
```

**Three practical points:**

1. **Validate the `next` parameter.** Redirecting straight to `next` is an open redirect. Always confirm it starts with `/` and not `//`.
2. **Don't return detailed error reasons.** "No account exists for this email" is the front door to user enumeration. The official docs note that `resetPasswordForEmail` "doesn't reveal whether an account exists for the given email address." Hold the same posture in your own handlers.
3. **Get the redirect allowlist right.**

### The redirect allowlist and wildcards

URLs passed to `redirectTo` must be registered in the dashboard's Redirect URLs. Understand the wildcards precisely:

| Pattern | Matches |
| --- | --- |
| `http://localhost:3000/*` | `/foo`, `/bar` (**not** `/foo/bar`) |
| `http://localhost:3000/**` | `/foo`, `/bar`, `/foo/bar` |
| `http://localhost:3000/?` | Single-character paths only |

Separators are `.` and `/`. `*` does not cross a separator; `**` does — that distinction is where accidents come from. For Vercel preview deployments the official recommendation is to set Site URL to your production URL and additionally register `http://localhost:3000/**` and `https://*-<team-or-account-slug>.vercel.app/**`.

---

## 4. JWT signing keys: from HS256 to ES256, with zero downtime

**This is the dividing line between an introduction and production quality.**

Supabase's legacy approach used a single shared secret (HS256) across the whole project. The official docs state this is "not recommended for production applications" due to revocation complexity and security risks.

The currently supported signing keys:

| Algorithm | JWT `alg` | Position |
| --- | --- | --- |
| NIST P-256 Curve | `ES256` | **Recommended for production.** "Elliptic Curves are a faster alternative than RSA" with shorter signatures |
| RSA 2048 | `RS256` | Widely supported but "significantly slower than elliptic curves" |
| Ed25519 Curve | `EdDSA` | Coming soon; runtime support is currently limited |
| HMAC shared secret | `HS256` | Not recommended for production |

### Why asymmetric keys make things faster

With asymmetric keys, **the public key is published via a JWKS endpoint**:

```text
GET https://project-id.supabase.co/auth/v1/.well-known/jwks.json
```

That endpoint is cached by Supabase's edge for 10 minutes. As a result `supabase.auth.getClaims()` can **verify locally, with no round trip to the Auth server**. The official explanation:

> This endpoint is often cached, resulting in significantly faster responses (compared to `getUser`). If your project uses symmetric JWT signing, the method sends a request to the Auth server — similar to `getUser` — since local verification isn't possible.

So **migrating to asymmetric keys is both a security improvement and a latency improvement.** In Next.js, where server components make authorization decisions on every render, the effect is substantial.

### Choosing between the three methods

| Method | What it does | Where to use it |
| --- | --- | --- |
| `getClaims()` | Verifies locally via JWKS with asymmetric keys; asks the server with symmetric keys | **First choice for server-side authorization** |
| `getUser()` | Asks the Auth server and verifies | When you need the freshest user information |
| `getSession()` | Returns a value from client storage **without revalidating** | Must not be used for server-side authorization |

### Migration steps (without forcing anyone to sign out)

Signing keys have four states:

```text
Standby       … Created but not yet used to issue tokens. Public key appears in JWKS
   ↓ rotate
In Use        … The key currently issuing tokens
   ↓ rotate
Previously    … The prior key. Still trusted for verifying existing tokens
Used
   ↓ revoke
Revoked       … No longer accepted for verification
```

The official dashboard procedure:

1. On the JWT signing keys page, click "Migrate JWT secret" — this imports your existing shared secret into the new system and simultaneously creates an asymmetric standby key
2. Review whether your app depends on the signing key **directly** (verifying by hand with `jose` or `jsonwebtoken`)
3. Click "Rotate keys" to make the asymmetric key current
4. Wait for the access token expiry (e.g. 1 hour) plus a buffer (15 minutes) before revoking the legacy secret

**Two things the official docs warn to check first:**

> Make sure your app does not directly rely on the legacy JWT secret. If it's verifying every JWT against the legacy JWT secret… continuing with the rotation might break those components.

> **If you're using Edge Functions that have the Verify JWT setting, continuing with the rotation might break your app. You will need to turn off this setting.**

As for user impact:

> Non-expired access tokens will remain to be accepted, so no users will be forcefully signed out.

If you verify tokens yourself anywhere, replace that with `getClaims()` before migrating.

```ts
// Before: hand-verifying with the shared secret (an obstacle to migration)
// const payload = jwt.verify(token, process.env.SUPABASE_JWT_SECRET!);

// After: independent of the signing key type
const { data, error } = await supabase.auth.getClaims();
if (error || !data) {
  // treat as unauthenticated
}
const userId = data.claims.sub;
const role = data.claims.role;
```

Note that `getClaims()` "is meant to be used only with JWTs issued by Supabase Auth" — it is not for verifying third-party IdP tokens.

---

## 5. Session design: you decide the lifetime

The official definition:

> A session is created when a user signs in. **By default, it lasts indefinitely** and a user can have an unlimited number of active sessions on as many devices.

"By default, it lasts indefinitely" — skim past that and a departed employee's laptop stays signed in forever.

| Item | Default | Notes |
| --- | --- | --- |
| Access token expiry | **1 hour** | "Most applications should use the default expiration time of 1 hour." Under 5 minutes is discouraged (server load, clock skew, errors during long-running requests) |
| Refresh token | **Never expires, single use** | Every use issues a new pair (rotation) |
| Refresh token reuse interval | **10 seconds** | "By default this is 10 seconds and we do not recommend changing this value" |
| Session time-box | Not set | **Pro plan and above** |
| Inactivity timeout | Not set | **Pro plan and above** |
| Single-session enforcement | Not set | **Pro plan and above** |

### Why the 10-second reuse interval exists

If a refresh token were strictly single-use, **having two tabs open would break you**. Both would attempt a refresh at nearly the same moment, and one would grab an already-used token and get signed out. The 10-second grace absorbs that race — which is exactly why the docs advise against changing it.

### What to configure

- **Leave the access token at 1 hour.** Shortening it multiplies refreshes and pushes you toward the IP-based limit on `/auth/v1/token` (1,800 requests per hour).
- **If you handle money or personal data, set a time-box and inactivity timeout** (Pro and above). "Lasts indefinitely" is guaranteed to come up in an audit.
- **Be deliberate about sign-out scope.** Whether to drop every device or just this session depends on the requirement.

---

## 6. MFA and AAL: enforce it in the database

Multi-factor authentication **must not be enforced by an application `if` statement**, because one missed path bypasses everything.

Supabase expresses authentication strength as **AAL (Authenticator Assurance Level)** in the JWT's `aal` claim.

- **aal1** — "identity was verified using a conventional login method such as email+password, magic link, one-time password, phone auth or social login"
- **aal2** — "identity was additionally verified using at least one second factor, such as a TOTP code or One-Time Password code"

When the claim is absent it is treated as `aal1`.

### From enrollment to verification

There are three API groups: **Enrollment** (adding and removing factors), **Challenge and Verify** (confirming access to a factor), and **List Factors** (displaying enrolled factors). Also available are `unenroll()`, `listFactors()`, and `getAuthenticatorAssuranceLevel()`. Supported factors are TOTP (authenticator apps) and phone.

### Enforce it in RLS

This is the heart of it. The shape the official docs show:

```sql
-- Require MFA for all users
create policy "mfa_required"
on public.sensitive_table
as restrictive
to authenticated
using ((select auth.jwt()->>'aal') = 'aal2');
```

```sql
-- Require aal2 only for users who have enrolled MFA (phased rollout)
create policy "mfa_required_for_enrolled"
on public.sensitive_table
as restrictive
to authenticated
using (
  array[(select auth.jwt()->>'aal')] <@ (
    select case
      when count(id) > 0 then array['aal2']
      else array['aal1', 'aal2']
    end
    from auth.mfa_factors
    where user_id = (select auth.uid()) and status = 'verified'
  )
);
```

**`as restrictive` is the essence.** Ordinary (permissive) policies combine with OR, so a single other policy can satisfy the condition. Restrictive policies combine with AND, so **the MFA condition applies no matter what any other policy permits**. The official docs likewise state that all such policies "should use the `as restrictive` clause to override permissive policies."

Wrapping in `(select auth.jwt()->>'aal')` is a performance optimization that avoids per-row re-evaluation (see [RLS performance optimization](/blog/supabase-rls-performance-optimization-select-wrap-index-guide)).

### The order of a phased rollout

1. Ship the MFA enrollment UI (optional enrollment)
2. Add the "require it only for enrolled users" policy above — unenrolled users are unaffected
3. Once adoption is high enough, make it mandatory for high-privilege roles such as administrators
4. Finally, switch everything to `= 'aal2'`

Starting at step 4 locks out every user.

---

## 7. Anonymous sign-ins: show the experience first, but defend it

For products where you want people to try before registering, anonymous sign-in works.

```ts
const { data, error } = await supabase.auth.signInAnonymously();
```

An anonymous user's JWT carries an `is_anonymous` claim. In the official wording: "JWTs for these users will have an `is_anonymous` claim which you can use to distinguish in RLS policies."

### Declare in RLS what anonymous users cannot do

```sql
create policy "Only permanent users can post to the news feed"
on public.news_feed
as restrictive
for insert
to authenticated
with check ((select (auth.jwt()->>'is_anonymous')::boolean) is false);
```

`as restrictive` again. "Can read but not write," "can try but not publish" — **declare that boundary in the database, not in the application**.

### Upgrading to a permanent account

- **Email / phone**: pass an address to `updateUser()`. A password can be added after verification
- **OAuth**: pass a provider to `linkIdentity()`

Identity linking carries an important constraint. Supabase **automatically links identities that share the same email address to one user**. But because "it would also be an insecure practice to automatically link an identity to a user with an unverified email address," email verification is a prerequisite. It is the linchpin of account-takeover prevention. Also, unlinking a single identity requires **at least two linked identities**.

### Abuse controls are mandatory

The official warning is explicit:

> Since anonymous users are stored in your database, bad actors can abuse the endpoint to increase your database size drastically.

Countermeasures:

- **Enable CAPTCHA** (officially recommended)
- The default IP-based limit is **30 requests per hour**, changeable in the dashboard
- **Build the periodic deletion of stale anonymous users into your operations from day one** (retrofitting it makes deciding which rows are safe to delete much harder)

---

## 8. Custom claims: putting permissions in the JWT

As covered in chapter 2, permissions cannot live in `user_metadata`. The sanctioned way to get them into the JWT is a **custom access token hook**.

The hook is a Postgres function, called by Auth just before it issues a token. Input and output are JSON:

```text
Input:  { "user_id": "...", "claims": { ... }, "authentication_method": "..." }
Output: { "claims": { ... } }
```

The official example (an access restriction hook) includes the grant / revoke convention:

```sql
create or replace function public.restrict_application_access(event jsonb)
 returns jsonb
 language plpgsql
as $function$
declare
    authentication_method text;
    email_claim text;
    allowed_emails text[] := array['myemail@company.com', 'example@company.com'];
begin
    email_claim = event->'claims'->>'email';
    authentication_method = event->'authentication_method';
    authentication_method = replace(authentication_method, '"', '');

    if email_claim ilike '%@supabase.io'
       or authentication_method = 'sso/saml'
       or email_claim = any(allowed_emails) then
        return event;
    end if;

    return jsonb_build_object(
        'error', jsonb_build_object(
            'http_code', 403,
            'message', 'Staging access is only allowed to team members. Please use your @company.com account instead'
        )
    );
end;
$function$
;

grant execute on function public.restrict_application_access to supabase_auth_admin;
revoke execute on function public.restrict_application_access from authenticated, anon, public;
```

**The last two lines matter most.** Grant execution only to `supabase_auth_admin` and strip it from `authenticated` / `anon` / `public`. Forget that, and ordinary users can call the hook function itself.

Attaching a role follows the same skeleton:

```sql
create or replace function public.custom_access_token_hook(event jsonb)
returns jsonb
language plpgsql
stable
as $$
declare
  claims jsonb;
  user_role text;
begin
  select role into user_role
  from public.user_roles
  where user_id = (event->>'user_id')::uuid;

  claims := event->'claims';

  if user_role is not null then
    claims := jsonb_set(claims, '{app_metadata,app_role}', to_jsonb(user_role));
  end if;

  return jsonb_set(event, '{claims}', claims);
end;
$$;

grant execute on function public.custom_access_token_hook to supabase_auth_admin;
revoke execute on function public.custom_access_token_hook from authenticated, anon, public;
```

### Watch out for JWT bloat

Don't dismiss the warning the docs attach:

> The size of the JWT can be a problem especially if you're using a Server-Side Rendering framework.

The JWT rides in a cookie and **travels on every request**. One role name is fine; stuff in a permissions array or large OAuth provider claims and you hit cookie size limits, with header size showing up as latency. The principle is: **put only the minimum needed for authorization decisions in the JWT, and read the details from the database.**

(How to express role-based authorization on the RLS side is covered in [RLS × RBAC and custom claims](/blog/supabase-rls-rbac-custom-claims-app-metadata-authorize-guide).)

---

## 9. Abuse controls: rate limits and passwords

Supabase Auth ships with default rate limits. The numbers worth knowing at design time:

| Operation | Path | Limited by | Default | Customizable |
| --- | --- | --- | --- | --- |
| Email send | `/auth/v1/signup`, `/auth/v1/recover`, `/auth/v1/user` | Project-wide | **2 emails per hour** (built-in provider) | Custom SMTP only |
| Send OTPs | `/auth/v1/otp` | Project-wide | 30 OTPs per hour | Yes |
| OTPs / magic links | `/auth/v1/otp` | Per user | 60-second window | Yes |
| Signup confirmation | `/auth/v1/signup` | Per user | 60-second window | Yes |
| Password reset | `/auth/v1/recover` | Per user | 60-second window | Yes |
| Verification requests | `/auth/v1/verify` | IP address | 360 requests per hour (bursts up to 30) | No |
| Token refresh | `/auth/v1/token` | IP address | 1,800 requests per hour (bursts up to 30) | No |
| MFA challenge / verify | `/auth/v1/factors/:id/challenge`, `/verify` | IP address | 15 requests per hour | No |
| Anonymous sign-ins | `/auth/v1/signup` | IP address | 30 requests per hour (bursts up to 30) | No |

**15 MFA attempts per hour** is a level legitimate use can reach. When a user keeps mistyping a code, don't stop at an error message — guide them to "wait and try again."

On the password side:

- **Minimum length** — "Anything less than 8 characters is not recommended"
- **Required character types** — "Use the strongest option of requiring digits, lowercase and uppercase letters, and symbols"
- **Leaked password protection** — rejects passwords known to HaveIBeenPwned's Pwned Passwords API. **Pro plan and above**

Existing users can keep signing in with their current passwords regardless of new requirements, but a weak password returns a `WeakPasswordError` on sign-in. Rather than swallowing it, the good design is to **turn it into a path to updating the password**.

---

## 10. Pre-production checklist

- [ ] JWT signing keys migrated to asymmetric (ES256), or a migration plan exists
- [ ] Hand-rolled JWT verification (`jose` / `jsonwebtoken`) replaced with `getClaims()`
- [ ] Edge Functions' Verify JWT setting audited (mandatory before key rotation)
- [ ] `getSession()` is not used for server-side authorization decisions
- [ ] Permissions are not in `user_metadata` (`app_metadata` or a dedicated table instead)
- [ ] The custom access token hook has both `grant` (supabase_auth_admin) and `revoke` (authenticated/anon/public)
- [ ] Claims put in the JWT are minimal (cookie size checked)
- [ ] `/auth/confirm` validates the `next` parameter (open-redirect defense)
- [ ] Error messages cannot be used for user enumeration
- [ ] The redirect allowlist was registered with the `*` vs `**` distinction understood
- [ ] Custom SMTP configured (the built-in provider allows 2 emails per hour)
- [ ] Session time-box / inactivity timeout configured per requirements
- [ ] MFA enforced by an `as restrictive` RLS policy (not by application `if` statements alone)
- [ ] If using anonymous sign-ins: CAPTCHA, `is_anonymous` restrictions in RLS, and a deletion routine for stale anonymous users
- [ ] Password strength settings and leaked-password protection (Pro and above) enabled
- [ ] `public.profiles` references **only the primary key** of `auth.users`

---

## Conclusion: the strength of authentication is decided by where the decision is made

Making Supabase Auth production-grade isn't about adding features. It's about **relocating the decisions**.

- Make the signing key asymmetric, and verification happens **locally with a JWKS public key**
- Put permissions in `app_metadata` and a hook, and decisions rest on **server-side facts**
- Write MFA into RLS, and enforcement **doesn't depend on the number of application paths**
- Write anonymous restrictions into RLS, and the boundary **is immune to missed code paths**

What they share is a single idea: **move the decision somewhere that does not depend on the application code being written correctly**. Authentication looks like a front-door concern, but it is really a decision about where the system's trust boundary sits. Projects that place it correctly at the start don't break as features accumulate.
