Supabase Authは、10行でサインインが動きます。そしてその10行のまま本番に出ると、たいてい3つの穴が空いています。
- JWTを共有シークレット(HS256)で署名したまま——鍵の失効が難しく、検証のたびにAuthサーバーへ往復している
- 権限を
user_metadataに置いている——ユーザー自身が書き換えられる場所に、権限を置いている - MFAを「アプリのif文」で強制している——1本の実装漏れで全部抜ける
この記事は、Supabase Authを本番品質にするための実装ガイドです。公式ドキュメント(2026-08-16時点)に忠実に、認証方式の選定から、非対称鍵への無停止移行、セッションの寿命設計、MFAのRLS強制、匿名サインインの安全な使い方、濫用対策までを、そのまま使えるコードで扱います。
この記事が扱わないこと:Next.js App Routerとの結線(
@supabase/ssr、createBrowserClient/createServerClient、CookieのgetAll/setAll、middlewareでのトークン更新)は、Next.js App RouterでSupabase RLSを正しく効かせるで1本まるごと扱っています。本記事はAuth側の設計に集中します。
1. 認証方式の選定:使う人で決まる
Supabase Authが提供する方式は、公式ドキュメントによれば次の通りです。
- パスワード認証(email + password)
- パスワードレス(マジックリンク / ワンタイムパスワード)
- ソーシャルログイン(19プロバイダ)
- 電話認証(MessageBird / Twilio / Vonage)
- エンタープライズSSO(SAML 2.0)
- 多要素認証(TOTP または 電話)
- カスタムプロバイダ(任意のOAuth2 / OIDC互換IdP)
加えて匿名サインインがあります。選定は「機能比較」ではなく「誰が使うか」で決めます。
| 想定ユーザー | 既定の方式 | 理由と注意点 |
|---|---|---|
| 一般消費者(B2C) | パスワード + ソーシャル | 摩擦が最小。ただしパスワードは強度設定と漏洩検知(Pro以上)を必ず有効化 |
| 買い切り・低頻度利用 | マジックリンク / OTP | パスワード管理そのものを無くせる。組み込みメールは1時間2通の制限があるためカスタムSMTPが事実上必須 |
| 社内システム・BtoB | SSO(SAML 2.0) | アカウントのライフサイクルをIdPに寄せられる。退職時の即時失効が要件なら唯一の現実解 |
| 「まず触ってほしい」プロダクト | 匿名サインイン → 昇格 | 体験を先に見せられる。ただし濫用対策必須(第7章) |
| 金銭・個人情報を扱う | 上記 + MFA | AALをRLSで強制する(第6章) |
メール系エンドポイントの制限は設計に直結します。 公式のレート制限表によれば、組み込みメールプロバイダでは /auth/v1/signup・/auth/v1/recover・/auth/v1/user がプロジェクト全体で1時間あたり2通です。開発中は気づきませんが、公開直後に確実に詰まります。カスタムSMTP(Resend、Amazon SES 等)の設定を、公開前チェックリストに入れてください。
2. ユーザーデータの置き場所:auth スキーマを触らない
サインインの次に必ず来るのが「ユーザーのプロフィールをどこに持つか」です。答えは**public スキーマの自前テーブル**です。公式は理由をこう説明しています。
主キーは変更されないことが保証される。Supabaseが管理するカラム・インデックス・制約その他のデータベースオブジェクトはいつでも変更される可能性がある。
つまり auth.users は主キーだけを外部キーとして参照してよい、それ以外の列やインデックスに依存してはいけない、ということです。
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 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();
security definer set search_path = '' はセットで書きます。search_path を空にしないと、検索パス経由で意図しないスキーマの関数が呼ばれ得るためです(詳細はSECURITY DEFINER関数とsearch_path)。
決定的に重要:user_metadata は認可に使えない
サインアップ時の raw_user_meta_data(= user_metadata)は、ユーザー自身が updateUser() で書き換えられます。ここに role: "admin" を置いて認可すると、そのアプリは誰でも管理者になれるアプリです。
権限は次のいずれかに置きます。
app_metadata— サーバー側からのみ書ける- 専用テーブル(
public.user_rolesなど)— RLSと組み合わせて管理する
そしてJWTに載せるのは第5章のカスタムアクセストークンフックです。
3. メール認証の正しい実装:token_hash と /auth/confirm
マジックリンクとメール確認は、実装を1箇所間違えるとリンクを踏んでもサインインできないという定番の詰まり方をします。正しい形はこうです。
まずメールテンプレートを {{ .TokenHash }} を使う形に変更します。
<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>
送信側は signInWithOtp です。
const { data, error } = await supabase.auth.signInWithOtp({
email: "valid.email@supabase.io",
options: {
// 未登録アドレスで勝手にユーザーを作らせたくないなら false
shouldCreateUser: false,
emailRedirectTo: "https://example.com/welcome",
},
});
受け側の /auth/confirm で verifyOtp に渡してセッションに交換します。
// 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;
// オープンリダイレクト防止: 外部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) {
// エラー詳細をURLに載せない(ユーザー列挙・情報漏れの経路になる)
return NextResponse.redirect(new URL("/auth/error?reason=invalid_token", origin));
}
return NextResponse.redirect(new URL(next, origin));
}
3つの実務ポイントがあります。
nextパラメータを検証する。nextをそのままリダイレクト先にすると、オープンリダイレクトになります。/始まりかつ//始まりでないことを必ず確認します。- エラー理由を詳細に出さない。 「このメールアドレスは存在しません」はユーザー列挙の入口です。公式も
resetPasswordForEmailについて「そのメールアドレスのアカウントが存在するかどうかを明かさない」と設計意図を述べています。同じ姿勢を自分のハンドラでも保ちます。 - リダイレクトURLの許可リストを正しく書く。
リダイレクト許可リストとワイルドカード
redirectTo に指定するURLは、ダッシュボードの Redirect URLs に登録されている必要があります。ワイルドカードの意味は正確に理解してください。
| パターン | マッチするもの |
|---|---|
http://localhost:3000/* | /foo、/bar(/foo/bar はマッチしない) |
http://localhost:3000/** | /foo、/bar、/foo/bar |
http://localhost:3000/? | 1文字のパスのみ |
区切り文字は . と / です。* は区切りを跨がず、** は跨ぐ——ここが事故の元です。Vercelのプレビューデプロイを使う場合、公式の推奨は Site URL を本番URLにし、加えて http://localhost:3000/** と https://*-<team-or-account-slug>.vercel.app/** を登録する形です。
4. JWT署名鍵:HS256 から ES256 へ、無停止で
ここが本番品質と入門の分水嶺です。
Supabaseの従来方式は、プロジェクト全体で1つの共有シークレット(HS256)を使うものでした。公式はこれを「失効の複雑さとセキュリティリスクのため、本番アプリケーションには推奨しない」と明言しています。
現在サポートされる署名鍵はこうです。
| アルゴリズム | JWT alg | 位置づけ |
|---|---|---|
| NIST P-256 楕円曲線 | ES256 | 本番推奨。「楕円曲線はRSAより高速な代替」で署名も短い |
| RSA 2048 | RS256 | 広くサポートされるが「楕円曲線より著しく低速」 |
| Ed25519 | EdDSA | 近日対応。現時点ではランタイム側の対応が限定的 |
| HMAC 共有シークレット | HS256 | 本番非推奨 |
なぜ非対称鍵にすると速くなるのか
非対称鍵にすると、公開鍵がJWKSエンドポイントで配布されます。
GET https://project-id.supabase.co/auth/v1/.well-known/jwks.json
このエンドポイントはSupabaseのエッジで10分キャッシュされます。結果として supabase.auth.getClaims() がAuthサーバーへの往復なしにローカル検証できるようになります。公式の説明はこうです。
このエンドポイントはしばしばキャッシュされるため、(
getUserに比べ)大幅に高速なレスポンスとなる。プロジェクトが対称鍵署名の場合は、ローカル検証ができないためgetUserと同様にAuthサーバーへリクエストを送る。
つまり 非対称鍵への移行は、セキュリティ改善であると同時にレイテンシ改善です。サーバーコンポーネントが毎レンダリングで認可判断をするNext.jsでは、効き方が大きい。
3つのメソッドの使い分け
| メソッド | 何をするか | 使う場所 |
|---|---|---|
getClaims() | 非対称鍵ならJWKSでローカル検証。対称鍵ならサーバー問い合わせ | サーバー側の認可判断の第一選択 |
getUser() | Authサーバーに問い合わせて検証 | 最新のユーザー情報が必要なとき |
getSession() | クライアントストレージ由来の値を再検証せずに返す | サーバーでの認可判断に使ってはいけない |
移行手順(ユーザーを強制サインアウトさせない)
署名鍵は4つの状態を持ちます。
Standby … 作成済みだが、まだトークン発行には使われない。公開鍵はJWKSに載る
↓ rotate
In Use … 現在トークンを発行している鍵
↓ rotate
Previously … 直前の鍵。既存トークンの検証のためにまだ信頼される
Used
↓ revoke
Revoked … 検証にも使われない
公式のダッシュボード手順はこうです。
- JWT signing keys ページで「Migrate JWT secret」— 既存の共有シークレットを新方式に取り込み、同時に非対称のstandby鍵を作成する
- アプリが署名鍵に直接依存していないか確認する(
joseやjsonwebtokenで自前検証していないか) - 「Rotate keys」で非対称鍵を現行にする
- アクセストークンの有効期限(例:1時間)+バッファ(15分)を待ってから、旧シークレットをrevokeする
事前に必ず確認する2点を公式が警告しています。
レガシーのJWTシークレットにアプリが直接依存していないことを確認すること。すべてのJWTをレガシーシークレットで検証している構成のままローテーションを続けると、それらのコンポーネントが壊れる可能性がある。
Verify JWT 設定を使っているEdge Functionsがある場合、ローテーションを続けるとアプリが壊れる可能性がある。この設定をオフにする必要がある。
一方、ユーザー影響についてはこうです。
期限切れでないアクセストークンは引き続き受理されるため、強制的にサインアウトされるユーザーはいない。
自前で検証している箇所があるなら、先に getClaims() へ置き換えてから移行します。
// Before: 共有シークレットで自前検証(移行の障害になる)
// const payload = jwt.verify(token, process.env.SUPABASE_JWT_SECRET!);
// After: 署名鍵の種類に依存しない
const { data, error } = await supabase.auth.getClaims();
if (error || !data) {
// 未認証として扱う
}
const userId = data.claims.sub;
const role = data.claims.role;
なお getClaims() は「Supabase Authが発行したJWTでのみ使うためのもの」です。サードパーティIdPのトークンを検証する用途には使いません。
5. セッション設計:寿命を決めるのは自分
公式の定義はこうです。
セッションはユーザーがサインインしたときに作られる。既定では無期限に続き、ユーザーは好きなだけのデバイスで無制限の数のアクティブセッションを持てる。
「既定では無期限」——ここを読み飛ばすと、退職者の端末に残ったセッションが延々生き続けます。
| 項目 | 既定値 | 補足 |
|---|---|---|
| アクセストークン有効期限 | 1時間 | 公式は「多くのアプリは既定の1時間を使うべき」。5分未満は非推奨(サーバー負荷、クロックスキュー、長時間リクエスト中のエラー) |
| リフレッシュトークン | 期限なし・1回だけ使用可 | 使うたびに新しいペアが発行される(ローテーション) |
| リフレッシュトークン再利用間隔 | 10秒 | 「既定は10秒で、この値の変更は推奨しない」 |
| セッション時間上限(タイムボックス) | 未設定 | Proプラン以上 |
| 無操作タイムアウト | 未設定 | Proプラン以上 |
| 単一セッション強制 | 未設定 | Proプラン以上 |
再利用間隔(10秒)が存在する理由
リフレッシュトークンが厳密に1回きりだと、タブを2枚開いているだけで壊れます。両方のタブがほぼ同時に更新を試み、片方が「使用済みトークン」を掴んでサインアウトさせられるからです。10秒の猶予はこの競合を吸収するためのもので、だから公式は変更を推奨しません。
何を設定すべきか
- アクセストークンは1時間のままにする。短くするとリフレッシュ回数が増え、
/auth/v1/tokenのIPベース制限(1時間あたり1,800リクエスト)に近づきます。 - 金銭・個人情報を扱うならタイムボックスと無操作タイムアウトを設定する(Pro以上)。「無期限に続く」既定は、監査で必ず指摘されます。
- サインアウトのスコープを意識する。 全デバイスから落とすのか、そのセッションだけかは要件次第です。
6. MFA と AAL:DBで強制する
多要素認証は、アプリのif文で強制してはいけません。1本の実装漏れで全部抜けるからです。
Supabaseは認証の強度を**AAL(Authenticator Assurance Level)**としてJWTの aal クレームに持ちます。
- aal1 — 「メール+パスワード、マジックリンク、ワンタイムパスワード、電話認証、ソーシャルログインといった従来型のログイン方法で本人確認された」
- aal2 — 「TOTPコードやワンタイムパスワードコードといった、少なくとも1つの第二要素で追加的に本人確認された」
クレームが無い場合は aal1 として扱われます。
登録から検証まで
APIは3つです。Enrollment(要素の追加・削除)、Challenge / Verify(要素へのアクセス確認)、List Factors(登録済み要素の表示)。加えて unenroll()、listFactors()、getAuthenticatorAssuranceLevel() があります。サポートされる要素はTOTP(認証アプリ)と電話です。
RLSで強制する
ここが本題です。公式が示す形はこうです。
-- 全ユーザーにMFAを要求する
create policy "mfa_required"
on public.sensitive_table
as restrictive
to authenticated
using ((select auth.jwt()->>'aal') = 'aal2');
-- 「MFAを登録済みのユーザーにだけ」aal2 を要求する(段階的導入)
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 が本質です。通常のポリシー(permissive)はORで結合されるため、他のポリシー1本で条件が満たされてしまいます。restrictiveはANDで結合されるため、他のポリシーが何を許可しようとMFA条件は必ず適用されます。公式も「すべてのポリシーは permissive なポリシーを上書きするために as restrictive 句を使うべき」と述べています。
(select auth.jwt()->>'aal') と (select ...) で包むのは、行ごとの再評価を避ける性能最適化です(詳細はRLSの性能最適化)。
段階的導入の順序
- MFAの登録UIを出す(任意登録)
- 上の「登録済みのユーザーにだけ要求する」ポリシーを入れる — 未登録者は影響を受けない
- 登録率が十分になったら、管理者ロールなど高権限から必須化する
- 最後に全体を
= 'aal2'に切り替える
いきなり4から始めると、全ユーザーが締め出されます。
7. 匿名サインイン:体験を先に見せる、ただし守る
「登録前に触ってほしい」プロダクトでは、匿名サインインが効きます。
const { data, error } = await supabase.auth.signInAnonymously();
匿名ユーザーのJWTには is_anonymous クレームが載ります。公式の言葉では「これらのユーザーのJWTには is_anonymous クレームがあり、RLSポリシーで区別に使える」。
RLSで「匿名にできないこと」を宣言する
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 です。「読めるが書けない」「試せるが公開できない」という境界を、アプリではなくDBに宣言します。
永続アカウントへの昇格
- メール / 電話:
updateUser()にアドレスを渡す。検証後にパスワードを設定できる - OAuth:
linkIdentity()にプロバイダを渡す
アイデンティティのリンクには重要な制約があります。Supabaseは同じメールアドレスを持つアイデンティティを自動的に1ユーザーに紐づけます。ただし「検証されていないメールアドレスのユーザーにアイデンティティを自動リンクするのは安全でない慣行」であるため、メール検証が前提です。アカウント乗っ取り防止の要です。また、アイデンティティを1つ解除するには2つ以上リンクされている必要があります。
濫用対策は必須
公式の警告は明確です。
匿名ユーザーはデータベースに保存されるため、悪意ある利用者がエンドポイントを叩いてデータベースサイズを劇的に増やせる。
対策は次の通りです。
- CAPTCHA を有効化する(公式推奨)
- 既定のIPベース制限は1時間あたり30リクエスト。ダッシュボードで変更可能
- 古い匿名ユーザーを定期的に削除する運用を最初から入れる(後から作ると、消してよい行の判別が難しくなります)
8. カスタムクレーム:権限をJWTに載せる
第2章で述べた通り、権限は user_metadata に置けません。JWTに載せる正規の方法がカスタムアクセストークンフックです。
フックはPostgres関数として書き、Authがトークンを発行する直前に呼ばれます。入出力はJSONです。
入力: { "user_id": "...", "claims": { ... }, "authentication_method": "..." }
出力: { "claims": { ... } }
公式が示す例(アクセス制限フック)は、grant / revoke の作法まで含めてこうなっています。
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;
最後の2行が最重要です。supabase_auth_admin にだけ実行を許し、authenticated / anon / public からは剥奪する。これを忘れると、フック関数そのものを一般ユーザーが呼べてしまいます。
ロールを載せる場合も同じ骨格です。
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;
JWT肥大に注意
公式が付けている警告を軽視しないでください。
特にサーバーサイドレンダリングのフレームワークを使っている場合、JWTのサイズが問題になり得る。
JWTはCookieに乗り、すべてのリクエストで往復します。ロール名1つなら問題ありませんが、権限の配列やOAuthプロバイダ由来の大きなクレームを詰め込むと、Cookieサイズの上限に当たり、ヘッダサイズがレイテンシに効きます。JWTには「認可判断に必要な最小限」だけを載せ、詳細はDBから引くのが原則です。
(ロールベースの認可をRLS側でどう表現するかは、RLS × RBAC・カスタムクレームで詳しく扱っています。)
9. 濫用対策:レート制限とパスワード
Supabase Authには既定のレート制限が入っています。設計時に把握しておくべき数字はこれです。
| 操作 | パス | 制限単位 | 既定値 | 変更可否 |
|---|---|---|---|---|
| メール送信 | /auth/v1/signup, /auth/v1/recover, /auth/v1/user | プロジェクト全体 | 1時間2通(組み込みプロバイダ) | カスタムSMTPのみ |
| OTP送信 | /auth/v1/otp | プロジェクト全体 | 1時間30通 | 可 |
| OTP / マジックリンク | /auth/v1/otp | ユーザー単位 | 60秒間隔 | 可 |
| サインアップ確認 | /auth/v1/signup | ユーザー単位 | 60秒間隔 | 可 |
| パスワードリセット | /auth/v1/recover | ユーザー単位 | 60秒間隔 | 可 |
| 検証リクエスト | /auth/v1/verify | IPアドレス | 1時間360回(バースト30) | 不可 |
| トークン更新 | /auth/v1/token | IPアドレス | 1時間1,800回(バースト30) | 不可 |
| MFA challenge / verify | /auth/v1/factors/:id/challenge, /verify | IPアドレス | 1時間15回 | 不可 |
| 匿名サインイン | /auth/v1/signup | IPアドレス | 1時間30回(バースト30) | 不可 |
MFAの1時間15回は、正当な利用でも当たり得る水準です。ユーザーがコードを打ち間違え続けたときのUIを、エラー表示だけで終わらせず「時間をおいて再試行」まで案内してください。
パスワード側の設定はこうです。
- 最小文字数 — 「8文字未満は非推奨」
- 必須文字種 — 「数字・小文字・大文字・記号をすべて要求する最強のオプションを使う」
- 漏洩パスワード保護 — HaveIBeenPwned の Pwned Passwords API で既知の漏洩パスワードを拒否。Proプラン以上
既存ユーザーは新しい要件に関係なく現在のパスワードでサインインできますが、弱いパスワードの場合はサインイン時に WeakPasswordError が返ります。これを握りつぶさず、パスワード更新への導線にするのが良い設計です。
10. 本番前チェックリスト
- JWT署名鍵を非対称鍵(ES256)に移行した/移行計画がある
- 自前のJWT検証(
jose/jsonwebtoken)をgetClaims()に置き換えた - Edge Functions の Verify JWT 設定を確認した(鍵ローテーション前に必須)
- サーバー側の認可判断で
getSession()を使っていない - 権限を
user_metadataに置いていない(app_metadataまたは専用テーブル) - カスタムアクセストークンフックに
grant(supabase_auth_admin)とrevoke(authenticated/anon/public)がある - JWTに載せるクレームが最小限である(Cookieサイズを見た)
-
/auth/confirmのnextパラメータを検証している(オープンリダイレクト防止) - エラーメッセージがユーザー列挙に使えない
- リダイレクトURL許可リストの
*と**の違いを理解して登録した - カスタムSMTPを設定した(組み込みは1時間2通)
- セッションのタイムボックス/無操作タイムアウトを要件に沿って設定した
- MFAを
as restrictiveのRLSで強制している(アプリのif文だけに頼らない) - 匿名サインインを使うなら、CAPTCHA・RLSでの
is_anonymous制限・古い匿名ユーザーの削除運用がある - パスワード強度設定と漏洩パスワード保護(Pro以上)を有効化した
-
public.profilesがauth.usersの主キーのみを参照している
まとめ:認証の強度は「どこで判断しているか」で決まる
Supabase Authを本番品質にする作業は、機能を足すことではありません。判断の場所を正しく置き直すことです。
- 署名鍵を非対称にすれば、検証はJWKSの公開鍵で局所的に行える
- 権限を
app_metadataとフックに置けば、判断はサーバー側の事実に基づく - MFAをRLSに書けば、強制はアプリの経路数に依存しない
- 匿名の制限をRLSに書けば、境界は実装漏れの影響を受けない
いずれも共通しているのは、「アプリのコードが正しく書かれていること」に依存しない場所へ判断を移すという発想です。認証は入口の話に見えて、その実、システム全体の信頼境界をどこに引くかという設計判断です。ここを最初に正しく置けたプロジェクトは、後から機能が増えても壊れません。