# Flask の認証実装ガイド：Flask-Login（セッション認証）と Flask-JWT-Extended（トークン認証）の使い分けと本番実装

> Flask の認証を本番品質で実装するガイド。Flask-Login（0.6.3）のセッション認証と Flask-JWT-Extended（4.7.4）のトークン（JWT）認証を、クライアント種別ごとに使い分ける判断軸から、register/login/logout・@login_required・current_user・リフレッシュトークン・ブロックリスト・httpOnly Cookie + CSRF まで、公式ドキュメントに忠実な実コードで解説。自前認証とマネージドIdPの境界も honest に整理します。

- 公開日: 2026-06-26
- 著者: 友田 陽大
- タグ: Python, Flask, 認証, JWT, セキュリティ, バックエンド
- URL: https://tomodahinata.com/blog/flask-authentication-flask-login-jwt-extended-guide

## 要点

- 認証方式はクライアント種別で決まる。サーバーレンダリングWebはFlask-Login（セッション）、SPA/モバイル/サービス間はFlask-JWT-Extended（トークン）。両者は排他ではなく1アプリで併用できる
- Flask-Loginはセッション認証でパスワードのハッシュ化はしない。werkzeug.securityのgenerate_password_hash/check_password_hashが担う。UserMixin・user_loader・@login_required・current_userが核
- Flask-JWT-ExtendedはステートレスなJWT認証。JWT_SECRET_KEYはSECRET_KEYと別物。create_access_token/create_refresh_token・@jwt_required・get_jwt_identity・additional_claimsでロール・user_lookup_loaderでcurrent_user
- JWTの運び方はAPIならAuthorization: Bearer、ブラウザならhttpOnly Cookie + CSRF。localStorageトークンはXSSで即漏洩するため避ける。ステートレスゆえの失効はブロックリストで補う
- SSO/SAML・MFA・コンプライアンスが要件なら自前認証を捨ててマネージドIdP（Cognito/Auth0/Clerk）へ。認証フローはこの記事、認可（ロール・テナント分離）はDB/サーバー側の別責務

---

## **導入：Flask の認証は「核に無い」から、まず方式を選ぶ**

Flask には認証が**内蔵されていません**。これは [Flask 本番運用ガイド](/blog/flask-production-guide) で繰り返した「Flask は核（ルーティング・リクエスト/レスポンス・テンプレート・設定・コンテキスト）だけを提供し、ORM もフォームも認証も載せない」という設計思想の、当然の帰結です。だからこそ認証は、**「どの拡張を、どのクライアントに対して載せるか」という設計判断**から始まります。

選択肢は実質 2 つに集約されます。

1. **Flask-Login** — **セッション認証**。ログイン状態を Flask の署名付きセッション Cookie に持つ。サーバーレンダリングの Web アプリ（管理画面・社内ツール）に最適。
2. **Flask-JWT-Extended** — **トークン認証**。ログイン状態を JWT（JSON Web Token）に持ち、サーバーは状態を持たない。SPA・モバイル・サービス間 API に最適。

この記事は、[Flask セキュリティ実装ガイド](/blog/flask-security-sessions-csrf-secure-cookies-guide) の姉妹編です。あちらが「セッション Cookie の正体・SECRET_KEY・CSRF・XSS」という**境界の固め方**を扱ったのに対し、本稿は **「ログインフローそのもの」**——誰がログインしていて、どうログイン・ログアウトし、保護されたエンドポイントをどう守るか——を扱います。Cookie の署名や CSRF の仕組みはあちらに譲り、ここでは再説明しません。

筆者は、**経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python / Flask / SQLAlchemy / PostgreSQL で設計・実装**し、サーバーレンダリングの管理画面（Flask-Login）と JSON API（JWT）を**1 つのアプリで併用**して本番運用してきました。同時に、別案件では **AWS Cognito / OIDC** のようなマネージド IdP も実装しています。だからこそ本稿では、**「自前認証を書くべきでない場面」も honest に**書きます。認証は、自分で書くことが常に正解とは限らない領域です。

> 💡 **この記事で扱うバージョン**：**Flask-Login==0.6.3**（インストール可能な最新安定版。ドキュメントの "latest" には 0.7.0 が見えますが、これは未リリースの main ブランチです。必ず `0.6.3` をピンしてください）と **Flask-JWT-Extended==4.7.4** を前提とします。Flask 本体は 3.1 系を想定します。コードは両拡張の公式ドキュメントに基づきます。

---

## **1. 最初の判断：セッション認証か、トークン認証か**

認証方式の選択は、好みではなく**クライアント種別**で決まります。判断軸はただ一つ——**「ログイン状態を、サーバーが Cookie で持つか（セッション）、クライアントがトークンで持つか（ステートレス）」**です。

| クライアント種別 | 推奨 | 認証の持ち方 | 理由 |
|---|---|---|---|
| サーバーレンダリング Web（管理画面・社内ツール・Jinja） | **Flask-Login**（セッション） | サーバー署名 Cookie | ブラウザが Cookie を自動送信。CSRF 対策込みで枯れた手法。トークン管理が要らない |
| SPA（React / Vue が別オリジン or 同一オリジン） | **Flask-JWT-Extended**（トークン） | JWT（Cookie or ヘッダ） | API はステートレスにしたい。後述の通り Cookie+CSRF も選べる |
| モバイルアプリ（iOS / Android） | **Flask-JWT-Extended**（トークン） | JWT（ヘッダ） | Cookie ストアを持たない。`Authorization: Bearer` が自然 |
| サービス間（マイクロサービス / バッチ） | **Flask-JWT-Extended** or API キー | JWT or 鍵 | ブラウザが介在しない。状態共有を避けたい |

> 💡 **セッション認証は「古い」のではなく「枯れている」**。「JWT が新しくてモダン、セッションは古い」という言説をよく見ますが、これは誤りです。サーバーレンダリングの Web アプリにおいて、**セッション認証は今でも第一選択**です。ブラウザが Cookie を自動で運び、サーバー側で即座に失効でき（後述の通り JWT はこれが難しい）、CSRF さえ対策すれば堅牢です。JWT を「とりあえず」採用してブラウザの localStorage に置く設計は、後述の XSS リスクを背負い込むだけの劣化です。**「SPA / モバイルだから JWT」「サーバーレンダリングだから セッション」**——この素直な対応が基本です。

### 1.1 両者は排他ではない

重要な事実として、**Flask-Login と Flask-JWT-Extended は 1 つのアプリで併用できます**。実際の B2B SaaS では、

- **`/admin/*`（社内オペレーターの管理画面）** → サーバーレンダリング + Flask-Login（セッション）
- **`/api/v1/*`（顧客の SPA / モバイルが叩く JSON API）** → Flask-JWT-Extended（JWT）

という構成が定石です。Blueprint で経路を分け、それぞれに別の認証を効かせます。具体的な併用構成は §6 で示します。

### 1.2 そもそも自前認証を書くべきか — マネージド IdP という第三の道

ここで一度立ち止まります。**Flask-Login も Flask-JWT-Extended も「自前で認証を実装する」道具**です。パスワードハッシュ・ログインフォーム・トークン発行・失効を**自分のコードとDBで管理**することになります。これは多くの場合で適切ですが、**そうでない場面**があります。

次のいずれかに当てはまるなら、自前認証を書かず、**マネージド IdP**（AWS Cognito / Auth0 / Clerk / Supabase Auth）を採用すべきです。

- **SSO / SAML / 企業 IdP 連携**が要件（エンタープライズ顧客が Okta / Entra ID でログインしたい）
- **MFA（多要素認証）**を堅牢に提供する必要がある
- **ソーシャルログイン**（Google / GitHub / Apple）を多数サポートする
- **コンプライアンス**（SOC2 / パスワード漏洩監視 / 不審ログイン検知）を自分で背負いたくない

これらは「認証機能の追加」ではなく「**認証を一生メンテし続ける事業**」です。自前で書くと、パスワードリセット・メール検証・レート制限・漏洩パスワードのチェック・MFA・監査ログ……と無限に広がります。**自分が認証の専門事業者でないなら、ここはマネージドに払うのが合理的**です。この判断の詳細は [認証プラットフォーム選定ガイド（Cognito / Auth0 / Clerk / Supabase）](/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase) に分けています。

> ⚠️ **自前認証の隠れたコスト**：「ログイン機能を作るだけ」という見積もりは、ほぼ必ず破綻します。本番の自前認証には、パスワードのハッシュ化（§3.3）、ブルートフォース対策（レート制限・ロックアウト）、セッション固定対策（§3.7）、パスワードリセットのトークン管理、メール検証、漏洩パスワードの拒否……が付随します。本稿はこれらを Flask で実装する方法を示しますが、**「自分で全部背負う覚悟があるか」を最初に問うてください**。覚悟がないなら IdP です。本稿の知見は、IdP を使う場合でも「IdP が裏で何をやっているか」の理解として効きます。

---

## **2. 認証（Authentication）と認可（Authorization）を分離する**

実装に入る前に、混同されがちな 2 つの概念を厳密に分けます。本稿のスコープを明確にするためです。

| 概念 | 問い | 本稿での扱い |
|---|---|---|
| **認証（AuthN）** | 「**あなたは誰か**」を確かめる | **本稿の主題**（ログイン・トークン発行・`current_user` の確立） |
| **認可（AuthZ）** | 「あなたは**何をしてよいか**」を確かめる | 本稿は入り口（ロール判定）まで。本体は別記事 |

この記事が確立するのは **`current_user`（いまログインしているユーザー）が誰か**までです。その `current_user` が「このテナントのこのリソースを更新してよいか」という**認可は、認証とは別の責務**で、サーバー側ロジックと DB（PostgreSQL の RLS など）で判定します。

> ⚠️ **`@login_required` / `@jwt_required()` は認可ではない**。これらは「ログインしているか（＝認証済みか）」しか見ません。「このユーザーがこのデータにアクセスしてよいか」は別途、ビュー内で DB と突き合わせて判定する必要があります。`session['user_id']` や JWT の `sub` が改ざんできないことと、その ID が当該リソースの所有者であることは、まったく別の保証です。認証だけ通して認可を忘れると、**他人のデータが見えるアクセス制御不備（IDOR）**が生まれます。マルチテナント環境での認可・データ分離の設計は [マルチテナント SaaS のデータ分離・認可設計ガイド](/blog/multi-tenant-saas-data-isolation-authorization-design-guide) に分けています。

本稿では §4.5 で「ロールに基づく最小限の認可フック（JWT クレーム + カスタムデコレータ）」までは示しますが、それはあくまで認証情報の上に薄く乗せる入り口です。本格的な認可は上記の記事へ。

---

## **3. Flask-Login（セッション認証）の本番実装**

サーバーレンダリングの Web アプリにおける認証を、Flask-Login で実装します。**Flask-Login が管理するのは「ログイン状態」だけ**である、という役割の限定を最初に押さえます。

### 3.1 Flask-Login が「やること」と「やらないこと」

| Flask-Login が**やる**こと | Flask-Login が**やらない**こと |
|---|---|
| ログイン状態をセッション Cookie に保存する | パスワードのハッシュ化（→ `werkzeug.security`） |
| `current_user` プロキシを提供する | ユーザーモデルの定義（あなたが書く） |
| `@login_required` でビューを保護する | ユーザーの DB 保存（あなたの ORM） |
| ログイン/ログアウトのセッション操作 | CSRF 対策（→ Flask-WTF） |
| Remember-me（永続ログイン）Cookie | パスワードリセット・メール検証 |

つまり Flask-Login は「**認証状態の管理レイヤ**」であり、パスワードの検証・保存は自分で（`werkzeug.security` と ORM で）やります。この分離を理解しないと「Flask-Login がパスワードを安全に保存してくれる」という致命的な誤解に陥ります。

### 3.2 セットアップ：ファクトリで `init_app`

他の拡張と同じく、[アプリケーションファクトリ](/blog/flask-application-factory-blueprints-large-app-structure-guide)に整合させ、「未束縛で生成 → `init_app` で束縛」のパターンに乗せます。

```python
# extensions.py — どのアプリにも束縛されていない「裸」の拡張
from flask_login import LoginManager

login_manager = LoginManager()
```

```python
# __init__.py — アプリケーションファクトリ内で束縛
from flask import Flask

from .extensions import db, login_manager


def create_app():
    app = Flask(__name__)
    app.config["SECRET_KEY"] = os.environ["SECRET_KEY"]  # セッション署名の根（セキュリティ記事 §2）

    db.init_app(app)
    login_manager.init_app(app)

    # 未認証アクセス時のリダイレクト先（auth Blueprint の login ビュー）
    login_manager.login_view = "auth.login"
    # セッション保護を最強に（IP/User-Agent 変化で再認証を要求）
    login_manager.session_protection = "strong"

    from .blueprints.auth import bp as auth_bp
    app.register_blueprint(auth_bp)
    return app


@login_manager.user_loader
def load_user(user_id: str):
    # セッションに保存された user_id から User を復元する。
    # user_id は str で渡るので int に変換（主キーが int の場合）
    return db.session.get(User, int(user_id))
```

押さえるべき 3 つの設定。

- **`user_loader`** — Flask-Login の心臓部です。リクエストごとに、セッション内の `user_id` から **`User` オブジェクトを復元**します。これが `current_user` の実体を供給します。`db.session.get(User, ...)` は SQLAlchemy 2.x の主キー取得 API です。
- **`login_manager.login_view = "auth.login"`** — `@login_required` のビューに未認証でアクセスしたとき、ここへリダイレクトします（値は Blueprint 名を含むエンドポイント名）。
- **`login_manager.session_protection = "strong"`** — セッションの IP / User-Agent が変わると、`"strong"` ではセッションを破棄して再認証を要求します。セッションハイジャックの緩和になります。

### 3.3 ユーザーモデル：`UserMixin` とパスワードハッシュ

`User` モデルは **`UserMixin` を継承**します。これにより Flask-Login が必要とする 4 つの属性/メソッド——`is_authenticated` / `is_active` / `is_anonymous` / `get_id()`——が自動で備わります。

**パスワードは平文で保存せず、必ずハッシュ化**します。Flask-Login はこれをやらないので、Werkzeug の `werkzeug.security` を使います。

```python
from flask_login import UserMixin
from werkzeug.security import generate_password_hash, check_password_hash

from .extensions import db


class User(UserMixin, db.Model):
    id = db.Column(db.Integer, primary_key=True)
    email = db.Column(db.String(255), unique=True, nullable=False, index=True)
    password_hash = db.Column(db.String(255), nullable=False)
    is_active_flag = db.Column(db.Boolean, default=True, nullable=False)

    def set_password(self, password: str) -> None:
        # ハッシュ化してから保存。平文は決して残さない
        self.password_hash = generate_password_hash(password)

    def check_password(self, password: str) -> bool:
        # 平文を保存済みハッシュと照合（True/False を返す）
        return check_password_hash(self.password_hash, password)
```

> 💡 **`generate_password_hash` は salt 込みで安全**。`werkzeug.security.generate_password_hash` は、内部で salt を生成し、既定で十分に強いアルゴリズム（バージョンにより `scrypt` 等）でハッシュ化します。**自分で MD5 / SHA-256 を直接使ってはいけません**——あれらは高速すぎてブルートフォースに弱く、salt も付きません。`generate_password_hash(pw)` でハッシュ化し、`check_password_hash(hash, pw)` で照合する——この 2 つだけ覚えれば、パスワード保存の基本は満たせます。なお、より厳しい要件では `argon2` を別途検討しますが、Werkzeug の既定で大半の用途は十分です。

> ⚠️ **`is_active` は「列名と衝突」しやすい**。`UserMixin` は `is_active` を `True` を返すプロパティとして提供します。論理削除や無効化を表す列を持つなら、上記のように `is_active_flag` と別名にするか、`is_active` プロパティをオーバーライドします。`is_active` が `False` を返すユーザーは、`login_user()` しても**ログインできません**（Flask-Login が拒否する）。アカウント無効化をこの仕組みに乗せられます。

### 3.4 ログイン：照合 → `login_user` → セッション再生成

ログインビューの実装です。**パスワード照合 → セッション再生成 → `login_user`** の順を守ります。

```python
from flask import Blueprint, redirect, render_template, request, url_for, flash, session
from flask_login import login_user, logout_user, login_required, current_user

from ..models import User

bp = Blueprint("auth", __name__)


@bp.route("/login", methods=["GET", "POST"])
def login():
    if current_user.is_authenticated:
        return redirect(url_for("dashboard.index"))

    if request.method == "POST":
        email = request.form["email"]
        password = request.form["password"]
        user = User.query.filter_by(email=email).first()

        # ユーザー不在とパスワード不一致を「同じエラー」にまとめる（後述）
        if user is None or not user.check_password(password):
            flash("メールアドレスまたはパスワードが正しくありません。")
            return render_template("auth/login.html"), 401

        session.clear()                     # セッション固定攻撃の遮断（セキュリティ記事 §3.2）
        login_user(user, remember=True)     # Flask-Login がセッションに user_id を保存
        return redirect(_safe_next_target())

    return render_template("auth/login.html")
```

3 つの本番ポイント。

- **`login_user(user)` がセッションに `user.get_id()` を書き込む**。以降のリクエストでは、§3.2 の `user_loader` がこの ID から `User` を復元し、`current_user` として供給します。
- **エラーメッセージは「メールかパスワードが違う」と曖昧にまとめる**。「メールアドレスが存在しません」と「パスワードが違います」を区別すると、攻撃者に「どのメールが登録済みか」を教える**ユーザー列挙（user enumeration）**になります。
- **`session.clear()` をログイン直前に**。セッション固定攻撃の対策です。詳細は[セキュリティ記事 §3.2](/blog/flask-security-sessions-csrf-secure-cookies-guide) を参照。

> 💡 **タイミング攻撃も「同じ分岐」で潰す**：上のコードはユーザー不在とパスワード不一致を 1 つの分岐にまとめていますが、厳密にはユーザー不在時に `check_password` を呼ばないと、応答時間の差から「メールが存在するか」を推測されえます。機密性の高いアプリでは、ユーザー不在でもダミーハッシュに対して `check_password_hash` を実行し、応答時間を揃えます。ここまでやるかは脅威モデル次第ですが、「存在を漏らさない」という原則は一貫させてください。

### 3.5 `next` パラメータ：オープンリダイレクトを塞ぐ

`@login_required` がリダイレクトでログイン画面に飛ばすとき、`?next=/dashboard` のように元の遷移先を付けます。ログイン後にそこへ戻すのは良い UX ですが、**`next` を検証せずにリダイレクトすると、`?next=https://evil.example.com` で外部サイトへ飛ばす「オープンリダイレクト」脆弱性**になります。

```python
from urllib.parse import urlparse, urljoin
from flask import request, url_for


def _is_safe_url(target: str) -> bool:
    """target が同一ホスト内の相対遷移かを検証する。"""
    host_url = urlparse(request.host_url)
    redirect_url = urlparse(urljoin(request.host_url, target))
    return (
        redirect_url.scheme in ("http", "https")
        and host_url.netloc == redirect_url.netloc  # 同一ホストのみ許可
    )


def _safe_next_target() -> str:
    next_url = request.args.get("next")
    if next_url and _is_safe_url(next_url):
        return next_url
    return url_for("dashboard.index")  # 不正/未指定なら既定の安全な遷移先
```

> ⚠️ **`return redirect(request.args.get("next"))` を直書きしない**。これは典型的なオープンリダイレクト脆弱性です。`next` は外部入力なので、必ず**同一ホスト内の相対パスであることを検証**してから使います。許可リスト（同一 netloc）方式で弾くのが確実です。フィッシングの踏み台にされる前に塞いでください。

### 3.6 保護・ログアウト・`current_user`

```python
from flask import jsonify
from flask_login import login_required, logout_user, current_user


@bp.route("/logout", methods=["POST"])
@login_required
def logout():
    logout_user()        # セッションから user_id を削除
    session.clear()      # 念のため丸ごと破棄（消し忘れキー対策）
    return redirect(url_for("auth.login"))


@bp.route("/me")
@login_required          # 未認証なら login_view へリダイレクト
def me():
    # current_user は Flask-Login が供給するプロキシ。テンプレートでも使える
    return jsonify(id=current_user.id, email=current_user.email)
```

- **`@login_required`** — 未認証アクセスを `login_view` へリダイレクトします。
- **`current_user`** — どこからでも参照できるプロキシです。ビューでも **Jinja テンプレート内（`{{ current_user.email }}`）でも**使えます。未認証なら `AnonymousUserMixin`（`is_authenticated == False`）を指します。
- **ログアウトは `logout_user()` + `session.clear()`**。前者で Flask-Login の状態を消し、後者で残留キーごと破棄します。

### 3.7 機密操作の再認証：`fresh_login_required`

「ログイン済み」と「**いま本人がパスワードを入れたばかり**」は別の信頼度です。パスワード変更・メール変更・退会・支払い情報変更のような**機密操作**では、Remember-me Cookie で復帰しただけのセッションを信用すべきではありません。

Flask-Login はこれを **「フレッシュなセッション」** という概念で扱います。`login_user()` した直後のセッションは "fresh"、Remember-me Cookie から復帰したセッションは "non-fresh" です。

```python
from flask_login import fresh_login_required, login_required, confirm_login


@bp.route("/change-password", methods=["POST"])
@fresh_login_required     # fresh なセッションでないと login_view へ飛ばす
def change_password():
    # ここに来るのは「最近認証した」セッションだけ
    current_user.set_password(request.form["new_password"])
    db.session.commit()
    return redirect(url_for("auth.me"))


@bp.route("/reauth", methods=["POST"])
@login_required
def reauth():
    # 機密操作の前にパスワードを再入力させ、セッションを再 fresh 化する
    if current_user.check_password(request.form["password"]):
        confirm_login()   # セッションを再び fresh にする
        return redirect(request.args.get("next") or url_for("auth.me"))
    flash("パスワードが正しくありません。")
    return render_template("auth/reauth.html"), 401
```

- **`@fresh_login_required`** — `@login_required` より厳しく、**fresh なセッション**でなければ拒否します。Remember-me で復帰しただけのユーザーは、ここで再認証（`confirm_login()`）を求められます。
- **`confirm_login()`** — パスワード再入力に成功した後で呼ぶと、セッションを再び fresh にします。これで機密操作に進めます。

### 3.8 未認証時の挙動をカスタマイズ：`unauthorized_handler`

`login_view` はリダイレクトを返しますが、**API では JSON で 401 を返したい**こともあります。`@login_manager.unauthorized_handler` で挙動を差し替えられます。

```python
@login_manager.unauthorized_handler
def unauthorized():
    # API リクエスト（JSON 期待）には JSON 401、それ以外はログイン画面へ
    if request.path.startswith("/api/") or request.accept_mimetypes.best == "application/json":
        return jsonify(error="authentication required"), 401
    return redirect(url_for("auth.login", next=request.full_path))
```

> 💡 **Flask-Login の設定キー総まとめ**：`login_manager.login_view`（リダイレクト先）、`session_protection`（`"basic"` / `"strong"` / `None`）、`REMEMBER_COOKIE_DURATION`（Remember-me の寿命）、`REMEMBER_COOKIE_SECURE` / `REMEMBER_COOKIE_HTTPONLY`（Remember-me Cookie の属性——セッション Cookie とは**別の Cookie** なので、これらも本番では `Secure` / `HttpOnly` を固める必要があります）。Remember-me Cookie の属性を締め忘れるのは、よくある見落としです。

---

## **4. Flask-JWT-Extended（トークン認証）の本番実装**

SPA・モバイル・サービス間 API のための、**ステートレスなトークン認証**を実装します。Flask-Login がサーバー側セッションに状態を持つのに対し、**JWT はクライアントが状態（署名付きトークン）を持ち、サーバーは検証するだけ**——これがスケーラビリティと、後述する失効の難しさの両方の源です。

### 4.1 JWTManager と `JWT_SECRET_KEY`（SECRET_KEY と別物）

```python
# extensions.py
from flask_jwt_extended import JWTManager

jwt = JWTManager()
```

```python
# __init__.py（create_app 内）
from datetime import timedelta

app.config["JWT_SECRET_KEY"] = os.environ["JWT_SECRET_KEY"]   # ← SECRET_KEY とは別！
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
jwt.init_app(app)
```

> ⚠️ **`JWT_SECRET_KEY` は Flask の `SECRET_KEY` と別の鍵**。Flask-JWT-Extended は、JWT の署名に **`JWT_SECRET_KEY`**（未設定なら `SECRET_KEY` にフォールバック）を使います。**意図的に別の鍵を設定すべき**です。理由は責務分離——セッション署名の鍵と JWT 署名の鍵を分けておくと、片方が漏れても被害を局所化でき、ローテーションも独立に行えます。両方とも `python -c 'import secrets; print(secrets.token_hex())'` で生成し、環境変数から注入します。**`"super-secret"` のような既定値のままデプロイするのは論外**で、誰でもトークンを偽造できます。

> 💡 **HS256 か RS256 か**：上記は対称鍵（HS256）の構成で、自前の単一サービスならこれで十分です。一方、**トークンを発行する側と検証する側が別**（例：Cognito が発行した JWT を Flask が検証する）の場合は、**非対称鍵（RS256）+ JWKS による公開鍵検証**になります。サードパーティ発行の JWT を Flask で検証する設計は [Cognito の JWT を RS256 + JWKS で検証するガイド](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide) に分けています。本稿の以降は、自前発行の HS256 を前提に進めます。

### 4.2 ログイン：アクセストークン + リフレッシュトークンを発行

```python
from flask import Blueprint, jsonify, request
from flask_jwt_extended import create_access_token, create_refresh_token

from ..models import User

api_bp = Blueprint("api_auth", __name__, url_prefix="/api/v1")


@api_bp.route("/login", methods=["POST"])
def login():
    data = request.get_json()
    user = User.query.filter_by(email=data.get("email")).first()
    if user is None or not user.check_password(data.get("password", "")):
        return jsonify(error="bad credentials"), 401

    # identity は JSON シリアライズ可能な値。慣例的に str(user.id) を使う
    identity = str(user.id)
    access_token = create_access_token(identity=identity, fresh=True)
    refresh_token = create_refresh_token(identity=identity)
    return jsonify(access_token=access_token, refresh_token=refresh_token)
```

- **`identity` は JSON シリアライズ可能であること**。ユーザーオブジェクトをそのまま渡せません。慣例として **`str(user.id)`**（文字列の主キー）を渡します。これが JWT の `sub`（subject）クレームになります。
- **`fresh=True`** — Flask-Login の §3.7 と同じ概念です。ログイン直後のアクセストークンは "fresh" で、リフレッシュで再発行したトークンは（後述の通り）"non-fresh" です。機密操作は fresh なトークンを要求できます。

### 4.3 保護されたエンドポイント：`@jwt_required` と `get_jwt_identity`

公式の最小形に忠実な保護エンドポイントです。

```python
from flask_jwt_extended import jwt_required, get_jwt_identity


@api_bp.route("/me")
@jwt_required()                        # Authorization: Bearer <token> を要求・検証
def me():
    current_user_id = get_jwt_identity()   # ログイン時に渡した identity（str(user.id)）
    user = db.session.get(User, int(current_user_id))
    return jsonify(id=user.id, email=user.email), 200
```

クライアントは、保護されたリクエストに **`Authorization: Bearer <access_token>`** ヘッダを付けます。

```bash
curl -H "Authorization: Bearer eyJhbGci..." https://api.example.com/api/v1/me
```

### 4.4 リフレッシュトークン：短命アクセス + 長命リフレッシュ

JWT 設計の核心が、**アクセストークンとリフレッシュトークンの二段構え**です。

- **アクセストークン** — 短命（15 分程度）。毎リクエストに付き、漏洩時の被害窓を最小化する。
- **リフレッシュトークン** — 長命（30 日程度）。**新しいアクセストークンを発行するためだけ**に使える。公式の言葉では「リフレッシュトークンは、新しいアクセストークンを作るためだけに使える長命の JWT」です。

```python
from flask_jwt_extended import jwt_required, get_jwt_identity, create_access_token


@api_bp.route("/refresh", methods=["POST"])
@jwt_required(refresh=True)             # リフレッシュトークンでのみ通る
def refresh():
    identity = get_jwt_identity()
    # リフレッシュで再発行するアクセストークンは fresh=False（機密操作には再ログインが必要）
    new_access_token = create_access_token(identity=identity, fresh=False)
    return jsonify(access_token=new_access_token)
```

このライフサイクルを図にすると：

| ステップ | トークン | 寿命 | fresh |
|---|---|---|---|
| ログイン | access + refresh を発行 | 15分 / 30日 | access は `fresh=True` |
| 通常リクエスト | access を `Authorization` で送る | — | — |
| access 失効 | refresh で新 access を取得 | 15分 | 新 access は `fresh=False` |
| 機密操作 | fresh が要るので**再ログイン**を要求 | — | 再ログインで `fresh=True` |
| refresh 失効 | 再ログインが必要 | — | — |

> 💡 **`@jwt_required(refresh=True)` の意味**：このデコレータは「**リフレッシュトークンでしか通らない**」エンドポイントを作ります。アクセストークンで `/refresh` を叩いても弾かれ、逆にリフレッシュトークンで通常 API（`@jwt_required()`）を叩いても弾かれます。トークンの種別が分離されているため、「アクセストークンが漏れてもリフレッシュには使えない」「リフレッシュトークンが漏れても直接 API は叩けない」という二重の安全弁になります。`jwt_required()` の `fresh=True` 引数を併用すれば、機密操作だけ fresh を要求できます。

### 4.5 ロール認可：`additional_claims` + カスタムデコレータ

トークンに**ロール**を埋め込み、認可の入り口にします（本格的な認可は §2 のとおり別記事）。`create_access_token` の `additional_claims` でクレームを追加します。

```python
from functools import wraps
from flask import jsonify
from flask_jwt_extended import create_access_token, jwt_required, get_jwt


# ログイン時：ロールをクレームに埋める
access_token = create_access_token(
    identity=str(user.id),
    additional_claims={"roles": [r.name for r in user.roles]},
)


def roles_required(*required_roles: str):
    """指定ロールを持つトークンだけ通すデコレータ。"""
    def decorator(fn):
        @wraps(fn)
        @jwt_required()
        def wrapper(*args, **kwargs):
            claims = get_jwt()                       # 全クレームを dict で取得
            user_roles = set(claims.get("roles", []))
            if not set(required_roles).issubset(user_roles):
                return jsonify(error="insufficient role"), 403
            return fn(*args, **kwargs)
        return wrapper
    return decorator


@api_bp.route("/admin/users")
@roles_required("admin")
def list_users():
    return jsonify(users=[...])
```

- **`get_jwt()`** — 現在のトークンの**全クレーム**を dict で返します。`additional_claims` で埋めた `roles` をここで読みます。
- **クレームのロールは「即時失効しない」**点に注意。トークンを発行した後でユーザーのロールを剥奪しても、**既存トークンが期限切れになるまでは古いロールのまま**です。アクセストークンを短命にする理由がここにもあります。即時剥奪が要件ならブロックリスト（§4.7）を併用します。

### 4.6 `current_user` を JWT でも使う：`user_lookup_loader`

Flask-Login の `current_user` のように、**JWT でもユーザーオブジェクトを直接触りたい**——これを `@jwt.user_lookup_loader` で実現します。

```python
from flask_jwt_extended import current_user, jwt_required


@jwt.user_identity_loader
def user_identity_lookup(user):
    # user オブジェクト → トークンに入れる identity 文字列
    return str(user.id)


@jwt.user_lookup_loader
def user_lookup_callback(_jwt_header, jwt_payload):
    # トークンの identity → User オブジェクト（current_user に供給される）
    identity = jwt_payload["sub"]
    return db.session.get(User, int(identity))


@api_bp.route("/profile")
@jwt_required()
def profile():
    # flask_jwt_extended.current_user で User オブジェクトに直接アクセスできる
    return jsonify(id=current_user.id, email=current_user.email)
```

- **`@jwt.user_identity_loader`** — `create_access_token(identity=user)` に**ユーザーオブジェクトを直接渡せる**ようにし、内部で `str(user.id)` へ変換します。
- **`@jwt.user_lookup_loader`** — トークンから `User` を引き、`flask_jwt_extended.current_user` に供給します。これで Flask-Login と**同じ書き味**で書けます。ただし**毎リクエストで DB を引く**ため、ステートレスの利点が一部薄れる点は理解しておきます（必要な API だけで使う、キャッシュする等）。

### 4.7 トークン失効：ブロックリストでステートレスの弱点を補う

JWT 最大の弱点は **「サーバー側で即座に失効できない」**ことです。セッション認証ならサーバー側のセッションを消せば即ログアウトできますが、JWT は**期限切れまで有効**で、ログアウトや「不審だから今すぐ無効化」が原理的に難しい。

これを補うのが **ブロックリスト（blocklist / denylist）** パターンです。失効させたいトークンの `jti`（JWT ID）を Redis 等に記録し、毎リクエストで照合します。

```python
# 失効済み jti を Redis に保持する（高速・TTL でトークン寿命に同期）
from flask_jwt_extended import get_jwt


@jwt.token_in_blocklist_loader
def check_if_revoked(_jwt_header, jwt_payload) -> bool:
    jti = jwt_payload["jti"]
    # Redis に jti があれば失効済み → True を返すとそのトークンは弾かれる
    return redis_client.get(f"revoked:{jti}") is not None


@api_bp.route("/logout", methods=["POST"])
@jwt_required()
def logout():
    jti = get_jwt()["jti"]
    # トークンの残り寿命だけ Redis に記録（期限切れ後は照合不要なので TTL で自動削除）
    redis_client.set(f"revoked:{jti}", "1", ex=900)  # 15分（access の寿命）
    return jsonify(msg="logged out")
```

> ⚠️ **ブロックリストは JWT の「純粋なステートレス」を崩す**。ブロックリストを導入すると、毎リクエストで Redis 等を照合するため、**「サーバーは状態を持たない」という JWT の利点が一部失われます**。ここには本質的なトレードオフがあります——**「完全ステートレス（失効できない）」か「即時失効できる（ステートフルな照合が要る）」か**。多くの本番 API は後者を選び、アクセストークンを短命（数分〜15分）にして照合コストとセキュリティのバランスを取ります。「JWT ならステートレスで失効も完璧」は両立しない、と理解した上で設計してください。

---

## **5. JWT の運び方：Bearer ヘッダ vs httpOnly Cookie**

JWT を**どこに置き、どう送るか**は、セキュリティに直結する設計判断です。`JWT_TOKEN_LOCATION` の既定は `["headers"]` ですが、`"cookies"` / `"json"` / `"query_string"` も選べます。

### 5.1 三つの選択肢と、その危険度

| 置き場所 | 送り方 | XSS リスク | CSRF リスク | 向き |
|---|---|---|---|---|
| **`Authorization` ヘッダ**（メモリ保持） | `Bearer <token>` | 低（JS メモリのみ・永続化しない） | なし（手動ヘッダ） | **API / モバイル** |
| **httpOnly Cookie** | ブラウザが自動送信 | 低（JS から読めない） | **あり** → CSRF 対策必須 | **ブラウザ（SPA）** |
| **localStorage** | JS が読んでヘッダに付与 | **高**（XSS で全部盗まれる） | なし | **避ける** |

### 5.2 なぜ localStorage は危険か

「SPA だから JWT を localStorage に入れる」——これは最も広まっている**アンチパターン**です。

> ⚠️ **localStorage のトークンは XSS で即漏洩する**。localStorage は JavaScript から自由に読めます。アプリのどこか（自分のコード、あるいは**サードパーティの npm パッケージ**）に XSS の穴が 1 つでもあれば、注入されたスクリプトが `localStorage.getItem('token')` でトークンを盗み、攻撃者のサーバーへ送れます。httpOnly Cookie は JS から**読めない**ため、XSS が起きてもトークン自体は盗まれません（CSRF 対策は別途必要）。「localStorage はセッション Cookie より安全」という主張をたまに見ますが、**XSS 耐性では httpOnly Cookie が明確に優れます**。SPA でも、トークンは httpOnly Cookie に置くか、メモリ（JS 変数）に保持してページリロードで再取得する設計にします。

### 5.3 ブラウザ向け：httpOnly Cookie + CSRF（double-submit）

ブラウザ SPA で JWT を使うなら、**httpOnly Cookie に入れ、CSRF を対策する**のが正解です。Flask-JWT-Extended は Cookie モードを組み込みでサポートします。

```python
app.config["JWT_TOKEN_LOCATION"] = ["cookies"]
app.config["JWT_COOKIE_SECURE"] = True          # 本番は必ず True（HTTPS 限定）
app.config["JWT_COOKIE_CSRF_PROTECT"] = True    # double-submit CSRF 保護を有効化
app.config["JWT_COOKIE_SAMESITE"] = "Lax"
```

```python
from flask_jwt_extended import set_access_cookies, create_access_token


@api_bp.route("/login", methods=["POST"])
def login_cookie():
    # ... 認証 ...
    access_token = create_access_token(identity=str(user.id))
    response = jsonify(login=True)
    set_access_cookies(response, access_token)   # httpOnly Cookie としてセット
    return response
```

> 💡 **double-submit CSRF とは**：`JWT_COOKIE_CSRF_PROTECT=True` にすると、Flask-JWT-Extended は JWT 本体を httpOnly Cookie に、**CSRF 値を別の（JS から読める）`csrf_access_token` Cookie**に入れます。クライアントは状態変更リクエストで、その値を **`X-CSRF-TOKEN` ヘッダにエコーバック**します。サーバーは「Cookie の CSRF 値とヘッダの値が一致するか」を検証します。攻撃者のサイトは Cookie を読めない（同一オリジンポリシー）ため、正しいヘッダを付けられず、CSRF が成立しません。CSRF の原理そのものは[セキュリティ記事 §4](/blog/flask-security-sessions-csrf-secure-cookies-guide) を参照。

> ⚠️ **`JWT_COOKIE_SECURE` は本番で必ず `True`**。公式は「本番では常に `True` に設定すべき」と明言しています。`False` のままだと HTTP 平文でトークン入り Cookie が送られ、中間者に盗聴されえます。なお `query_string`（URL クエリにトークン）は、**URL がブラウザ履歴・プロキシログ・Referer に残る**ため、公式も推奨していません。使わないでください。

### 5.4 XSS と CSRF のトレードオフを正面から見る

ヘッダ方式と Cookie 方式は、**異なる攻撃に強い/弱い**という本質的なトレードオフがあります。

| 方式 | XSS への耐性 | CSRF への耐性 | 必要な対策 |
|---|---|---|---|
| `Authorization` ヘッダ（メモリ） | やや強（永続化しない） | 強（ブラウザが自動送信しない） | XSS そのものを起こさない（CSP・エスケープ） |
| httpOnly Cookie | 強（JS から読めない） | 弱 → **CSRF 対策必須** | `JWT_COOKIE_CSRF_PROTECT` + `SameSite` |

「どちらが安全か」に唯一の答えはありません。**モバイル / 外部 API → ヘッダ**、**ブラウザ SPA → httpOnly Cookie + CSRF**、という対応が実務の落としどころです。共通して言えるのは、**localStorage だけは選ばない**こと——XSS に対して最も脆弱だからです。

---

## **6. 本番例：1 アプリで Flask-Login と JWT を併用する**

ここまでを統合し、B2B SaaS で実際に使う「**サーバーレンダリング管理画面（Flask-Login）+ JSON API（JWT）**」の併用構成を示します。

```python
# __init__.py（create_app 内、抜粋）
def create_app():
    app = Flask(__name__)
    app.config.update(
        SECRET_KEY=os.environ["SECRET_KEY"],          # セッション署名（管理画面）
        JWT_SECRET_KEY=os.environ["JWT_SECRET_KEY"],  # JWT 署名（API）— 別の鍵
        SESSION_COOKIE_SECURE=True,
        SESSION_COOKIE_HTTPONLY=True,
        SESSION_COOKIE_SAMESITE="Lax",
        JWT_ACCESS_TOKEN_EXPIRES=timedelta(minutes=15),
    )

    db.init_app(app)
    login_manager.init_app(app)   # 管理画面用（セッション）
    jwt.init_app(app)             # API 用（トークン）
    csrf.init_app(app)            # Flask-WTF：ブラウザフォームの CSRF

    login_manager.login_view = "admin_auth.login"
    login_manager.session_protection = "strong"

    # 管理画面：Flask-Login で守る（サーバーレンダリング）
    from .blueprints.admin import bp as admin_bp
    app.register_blueprint(admin_bp, url_prefix="/admin")

    # JSON API：JWT で守る（CSRF は不要なので exempt）
    from .blueprints.api import bp as api_bp
    app.register_blueprint(api_bp, url_prefix="/api/v1")
    csrf.exempt(api_bp)           # Bearer 認証の API に CSRF トークンは原理的に不要

    return app
```

設計のポイント。

- **管理画面（`/admin/*`）は Flask-Login + CSRFProtect**。ブラウザフォームなので CSRF 対策が要ります。
- **API（`/api/v1/*`）は JWT で、`csrf.exempt`**。`Authorization: Bearer` のヘッダ認証はブラウザが自動送信しないため、CSRF の前提が崩れます（[セキュリティ記事 §4.5](/blog/flask-security-sessions-csrf-secure-cookies-guide) の詳説の通り）。**ただし Cookie モードで JWT を運ぶ場合は CSRF 対策が必要**——`JWT_COOKIE_CSRF_PROTECT` で別途対応します（§5.3）。
- **`User` モデルは共通**。両方の認証が同じ `users` テーブル・同じ `check_password` を使います。認証の「方式」は違っても、「誰か」を保存する場所は 1 つです。

### 6.1 本番で必須の周辺フック

認証フローの周りには、認証だけでは不十分な防御が要ります。

- **ログイン試行のレート制限・ロックアウト** — 同一 IP / 同一アカウントへの連続失敗をしきい値で止め、ブルートフォースを防ぎます。実装は `Flask-Limiter` 等で行い、過大入力の防御は [Flask セキュリティ記事 §7（DoS とリソース制限）](/blog/flask-security-sessions-csrf-secure-cookies-guide) と組み合わせます。
- **パスワードリセット** — `itsdangerous.URLSafeTimedSerializer` で有効期限付き・署名付きトークンを発行し、メールで送ります（セッションを再発明しない設計。セキュリティ記事 §1.3 参照）。
- **メール検証** — 同じく署名付きトークンで。
- **監査ログ** — ログイン成功/失敗・パスワード変更を記録（PII・秘密はログに残さない原則を守る）。

> 💡 **「認証は書けたが運用が崩れる」を避ける**：上の周辺フックを全部書いて初めて、自前認証は本番品質になります。これらの分量を見て「重い」と感じるなら、それは §1.2 の「マネージド IdP を使うべきサイン」かもしれません。筆者の B2B SaaS は、社内管理画面（少人数・SSO 不要）は自前 Flask-Login で十分でしたが、**顧客向けに SSO / MFA を出す段になったら、その層は IdP に寄せる**——という切り分けをしています。

---

## **7. 自前認証 vs マネージド IdP：境界を honest に引く**

最後に、本稿で散らした「自前で書くべきか」の判断を 1 つの表にまとめます。これは技術選定の核心です。

| 要件 | 自前（Flask-Login / JWT） | マネージド IdP（Cognito / Auth0 / Clerk） |
|---|---|---|
| メール + パスワードのログイン | ◎ 簡単 | ◎ |
| ソーシャルログイン（Google 等） | △ 各 provider を自前実装 | ◎ 設定だけ |
| **SSO / SAML（企業 IdP 連携）** | ✕ 実装地獄 | ◎ これが本領 |
| **MFA（多要素認証）** | △ 自前は重い | ◎ 組み込み |
| 漏洩パスワード検知・不審ログイン検知 | ✕ 自前は非現実的 | ◎ |
| **コンプライアンス（SOC2 等）の肩代わり** | ✕ 自分で背負う | ◎ |
| ベンダーロックインの回避 | ◎ | △ 移行コストあり |
| 月額コスト | サーバー代のみ | MAU 課金（規模で高額化） |
| データ主権（ユーザー情報を自社 DB に） | ◎ | △ provider 依存 |

意思決定の指針はシンプルです。

1. **社内ツール・小規模・メール+パスワードだけ** → **自前（本稿の Flask-Login / JWT）で十分**。依存を増やさず、データを自分の DB に持てる。
2. **SSO / SAML / MFA / コンプライアンスが要件に入った瞬間** → **マネージド IdP に寄せる**。これらを自前で本番品質に保つのは、認証専門事業者でない限り割に合わない。
3. **ハイブリッド** → 管理画面は自前、顧客向けの認証は IdP、という層別も現実的。

> 💡 **トークンの「意味」を取り違えない**：マネージド IdP を使うと、`id_token` と `access_token` という 2 種類の JWT が出てきて混乱しがちです。**ID トークンは「誰がログインしたか（認証の証明）」、アクセストークンは「何にアクセスしてよいか（認可の鍵）」**で、用途が違います。この区別を誤ると「ID トークンを API のアクセス制御に使う」ような設計ミスを犯します。OIDC / OAuth2 におけるこの 2 トークンの正しい使い分けは [ID トークン vs アクセストークンの徹底解説](/blog/id-token-vs-access-token-oidc-oauth2-guide) に分けています。IdP を採用するなら、実装前に必ず読んでください。

マネージド IdP の具体的な選定（Cognito / Auth0 / Clerk / Supabase Auth の比較と決め方）は [認証プラットフォーム選定ガイド](/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase) にまとめています。

---

## **まとめと Flask 認証チェックリスト**

Flask の認証は、核に無いからこそ「**どの方式を、どのクライアントに**」という選択から始まります。本稿の要点を再掲します。

1. **方式はクライアントで決まる**。サーバーレンダリング Web → **Flask-Login（セッション）**、SPA / モバイル / サービス間 → **Flask-JWT-Extended（トークン）**。両者は 1 アプリで併用できる。
2. **Flask-Login はセッション管理だけ**。パスワードのハッシュ化は `werkzeug.security` の `generate_password_hash` / `check_password_hash`。`UserMixin` + `user_loader` + `@login_required` + `current_user` が核。機密操作は `fresh_login_required`。
3. **Flask-JWT-Extended はステートレス**。`JWT_SECRET_KEY` は `SECRET_KEY` と別の鍵で生成・ローテーション。短命 access + 長命 refresh、`additional_claims` でロール、`user_lookup_loader` で `current_user`。
4. **JWT の即時失効はブロックリストで**。ただしステートレスの利点と引き換え。アクセストークンを短命にしてバランスを取る。
5. **トークンの運び方**：API は `Authorization: Bearer`、ブラウザは **httpOnly Cookie + `JWT_COOKIE_CSRF_PROTECT`**。**localStorage は XSS で漏れるので避ける**。
6. **認証 ≠ 認可**。`@login_required` / `@jwt_required()` は「ログイン済みか」しか見ない。「何をしてよいか」は DB / RLS で別途判定する。
7. **SSO / SAML / MFA / コンプライアンスが要件なら自前を捨ててマネージド IdP**。「ログイン機能」ではなく「認証を一生メンテする事業」だと見積もる。

本番投入前のチェックリストです。筆者が実際に確認している項目を整理しました。

| 区分 | チェック項目 | 確認 |
|---|---|---|
| 方式選定 | クライアント種別（Web / SPA / モバイル / サービス間）に合った方式を選んでいる | ☐ |
| 方式選定 | SSO / SAML / MFA / コンプライアンス要件があれば IdP を検討した | ☐ |
| パスワード | `generate_password_hash` で保存し、平文を一切残していない | ☐ |
| パスワード | 自前で MD5 / SHA を直接使っていない（salt 込みハッシュを使う） | ☐ |
| Flask-Login | `Flask-Login==0.6.3` をピン（0.7.0 は未リリース）、`User` が `UserMixin` 継承 | ☐ |
| Flask-Login | ログイン時に `session.clear()`、ログアウトで `logout_user()` + `session.clear()` | ☐ |
| Flask-Login | `next` パラメータを同一ホスト検証（オープンリダイレクト対策） | ☐ |
| Flask-Login | 機密操作に `fresh_login_required`、Remember-me Cookie の `Secure`/`HttpOnly` を固める | ☐ |
| Flask-Login | ログインエラーを曖昧化（ユーザー列挙対策） | ☐ |
| JWT | `JWT_SECRET_KEY` を `SECRET_KEY` と別に設定し、既定値でデプロイしていない | ☐ |
| JWT | access は短命（〜15分）、refresh は別種別（`@jwt_required(refresh=True)`） | ☐ |
| JWT | 即時失効が要件ならブロックリスト（`token_in_blocklist_loader`）を実装 | ☐ |
| JWT 運搬 | ブラウザは httpOnly Cookie + `JWT_COOKIE_SECURE` + `JWT_COOKIE_CSRF_PROTECT` | ☐ |
| JWT 運搬 | localStorage にトークンを保存していない（XSS 対策） | ☐ |
| 認可 | `@login_required` / `@jwt_required()` を認可と混同せず、DB / RLS で別途判定 | ☐ |
| 周辺 | ログイン試行のレート制限・ロックアウト、パスワードリセットは署名付きトークン | ☐ |

認証は「誰か」を確立する仕事、認可は「何をしてよいか」を確立する仕事です。本稿で `current_user` を正しく確立したら、その一段上——「認証されたユーザーが、許されたデータにだけアクセスできる」という認可の設計は [マルチテナント SaaS のデータ分離・認可設計ガイド](/blog/multi-tenant-saas-data-isolation-authorization-design-guide) へ。そして Flask 全体の設計対象（構成・コンテキスト・デプロイ・テスト・セキュリティ）との接続は [Flask 本番運用ガイド](/blog/flask-production-guide) に戻って俯瞰してください。認証は、自分で書くか・マネージドに払うかの判断を含めて、事業の信頼性を支える境界設計です。
