Chuyển đến nội dung chính
友田 陽大
Flask in production
Python
Flask
認証
JWT
セキュリティ
バックエンド

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 に整理します。

Published
Reading time
34 min read
Author
友田 陽大
Chia sẻ
Mục lục

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

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

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

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

この記事は、Flask セキュリティ実装ガイド の姉妹編です。あちらが「セッション 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) に分けています。

⚠️ 自前認証の隠れたコスト:「ログイン機能を作るだけ」という見積もりは、ほぼ必ず破綻します。本番の自前認証には、パスワードのハッシュ化(§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 のデータ分離・認可設計ガイド に分けています。

本稿では §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

他の拡張と同じく、アプリケーションファクトリに整合させ、「未束縛で生成 → init_app で束縛」のパターンに乗せます。

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

login_manager = LoginManager()
# __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 を使います。

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

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

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

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 を参照。

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

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

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

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

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 }})でも使えます。未認証なら AnonymousUserMixinis_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" です。

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 で挙動を差し替えられます。

@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 と別物)

# extensions.py
from flask_jwt_extended import JWTManager

jwt = JWTManager()
# __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 で検証するガイド に分けています。本稿の以降は、自前発行の HS256 を前提に進めます。

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

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_requiredget_jwt_identity

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

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> ヘッダを付けます。

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

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

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

  • アクセストークン — 短命(15 分程度)。毎リクエストに付き、漏洩時の被害窓を最小化する。
  • リフレッシュトークン — 長命(30 日程度)。新しいアクセストークンを発行するためだけに使える。公式の言葉では「リフレッシュトークンは、新しいアクセストークンを作るためだけに使える長命の JWT」です。
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_tokenadditional_claims でクレームを追加します。

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 で実現します。

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_loadercreate_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 等に記録し、毎リクエストで照合します。

# 失効済み 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 ならステートレスで失効も完璧」は両立しない、と理解した上で設計してください。


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

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

置き場所送り方XSS リスクCSRF リスク向き
Authorization ヘッダ(メモリ保持)Bearer <token>低(JS メモリのみ・永続化しない)なし(手動ヘッダ)API / モバイル
httpOnly Cookieブラウザが自動送信低(JS から読めない)あり → CSRF 対策必須ブラウザ(SPA)
localStorageJS が読んでヘッダに付与(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 変数)に保持してページリロードで再取得する設計にします。

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

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"
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 を参照。

⚠️ 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)」の併用構成を示します。

# __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.exemptAuthorization: Bearer のヘッダ認証はブラウザが自動送信しないため、CSRF の前提が崩れます(セキュリティ記事 §4.5 の詳説の通り)。ただし Cookie モードで JWT を運ぶ場合は CSRF 対策が必要——JWT_COOKIE_CSRF_PROTECT で別途対応します(§5.3)。
  • User モデルは共通。両方の認証が同じ users テーブル・同じ check_password を使います。認証の「方式」は違っても、「誰か」を保存する場所は 1 つです。

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

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

  • ログイン試行のレート制限・ロックアウト — 同一 IP / 同一アカウントへの連続失敗をしきい値で止め、ブルートフォースを防ぎます。実装は Flask-Limiter 等で行い、過大入力の防御は Flask セキュリティ記事 §7(DoS とリソース制限) と組み合わせます。
  • パスワードリセット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_tokenaccess_token という 2 種類の JWT が出てきて混乱しがちです。**ID トークンは「誰がログインしたか(認証の証明)」、アクセストークンは「何にアクセスしてよいか(認可の鍵)」**で、用途が違います。この区別を誤ると「ID トークンを API のアクセス制御に使う」ような設計ミスを犯します。OIDC / OAuth2 におけるこの 2 トークンの正しい使い分けは ID トークン vs アクセストークンの徹底解説 に分けています。IdP を採用するなら、実装前に必ず読んでください。

マネージド IdP の具体的な選定(Cognito / Auth0 / Clerk / Supabase Auth の比較と決め方)は 認証プラットフォーム選定ガイド にまとめています。


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

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

  1. 方式はクライアントで決まる。サーバーレンダリング Web → Flask-Login(セッション)、SPA / モバイル / サービス間 → Flask-JWT-Extended(トークン)。両者は 1 アプリで併用できる。
  2. Flask-Login はセッション管理だけ。パスワードのハッシュ化は werkzeug.securitygenerate_password_hash / check_password_hashUserMixin + user_loader + @login_required + current_user が核。機密操作は fresh_login_required
  3. Flask-JWT-Extended はステートレスJWT_SECRET_KEYSECRET_KEY と別の鍵で生成・ローテーション。短命 access + 長命 refresh、additional_claims でロール、user_lookup_loadercurrent_user
  4. JWT の即時失効はブロックリストで。ただしステートレスの利点と引き換え。アクセストークンを短命にしてバランスを取る。
  5. トークンの運び方:API は Authorization: Bearer、ブラウザは httpOnly Cookie + JWT_COOKIE_CSRF_PROTECTlocalStorage は 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-LoginFlask-Login==0.6.3 をピン(0.7.0 は未リリース)、UserUserMixin 継承
Flask-Loginログイン時に session.clear()、ログアウトで logout_user() + session.clear()
Flask-Loginnext パラメータを同一ホスト検証(オープンリダイレクト対策)
Flask-Login機密操作に fresh_login_required、Remember-me Cookie の Secure/HttpOnly を固める
Flask-Loginログインエラーを曖昧化(ユーザー列挙対策)
JWTJWT_SECRET_KEYSECRET_KEY と別に設定し、既定値でデプロイしていない
JWTaccess は短命(〜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 のデータ分離・認可設計ガイド へ。そして Flask 全体の設計対象(構成・コンテキスト・デプロイ・テスト・セキュリティ)との接続は Flask 本番運用ガイド に戻って俯瞰してください。認証は、自分で書くか・マネージドに払うかの判断を含めて、事業の信頼性を支える境界設計です。

友田

友田 陽大

Người phát triển sản phẩm đoạt Giải thưởng Bộ trưởng (METI). Với TypeScript + Python + AWS, tôi một mình triển khai trọn gói SaaS, chuyển đổi số công nghiệp và AI tạo sinh (RAG) sẵn sàng cho production — từ phân tích yêu cầu đến hạ tầng và vận hành.

Got a challenge?

From design to implementation and operations — solo × generative AI

Implementation like this article's, end to end from requirements to production. Start with a free 30-minute technical consult and tell me about your situation.

Nhận cả dự án trọn gói lẫn vai trò cố vấn kỹ thuật. Hãy bắt đầu bằng buổi tư vấn miễn phí 30 phút.

最短ルート:カレンダーから直接予約

相談内容が固まっている方は、フォーム送信よりその場で日程を確定する方がスムーズです。下記から空き時間をお選びください。

  • 30分のオンライン無料相談
  • Google Meet / Zoom / Microsoft Teams
  • NDA 商談前締結可・無理な営業はいたしません
無料相談の空き枠を予約する

Cũng đáng đọc