メインコンテンツへスキップ
友田 陽大
Flask 本番運用
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 に整理します。

公開日
読了時間
34分
著者
友田 陽大
シェア
目次

導入: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 本番運用ガイド に戻って俯瞰してください。認証は、自分で書くか・マネージドに払うかの判断を含めて、事業の信頼性を支える境界設計です。

友田

友田 陽大

経済産業大臣賞 受賞プロダクト開発者。TypeScript + Python + AWS で、SaaS・業界DX・ 実用レベルの生成AI(RAG)を、要件定義からインフラ・運用まで一人で完遂します。

この記事で解説した技術の適用事例

経済産業大臣賞受賞 | 木材流通業界のDXを実現したB2BサブスクリプションSaaS

ケーススタディを見る