導入:Flask の認証は「核に無い」から、まず方式を選ぶ
Flask には認証が内蔵されていません。これは Flask 本番運用ガイド で繰り返した「Flask は核(ルーティング・リクエスト/レスポンス・テンプレート・設定・コンテキスト)だけを提供し、ORM もフォームも認証も載せない」という設計思想の、当然の帰結です。だからこそ認証は、「どの拡張を、どのクライアントに対して載せるか」という設計判断から始まります。
選択肢は実質 2 つに集約されます。
- Flask-Login — セッション認証。ログイン状態を Flask の署名付きセッション Cookie に持つ。サーバーレンダリングの Web アプリ(管理画面・社内ツール)に最適。
- 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は「列名と衝突」しやすい。UserMixinはis_activeをTrueを返すプロパティとして提供します。論理削除や無効化を表す列を持つなら、上記のようにis_active_flagと別名にするか、is_activeプロパティをオーバーライドします。is_activeがFalseを返すユーザーは、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 }})でも使えます。未認証なら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" です。
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_required と get_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_token の additional_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_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 等に記録し、毎リクエストで照合します。
# 失効済み 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 モードを組み込みでサポートします。
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_tokenCookieに入れます。クライアントは状態変更リクエストで、その値を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.exempt。Authorization: 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 依存 |
意思決定の指針はシンプルです。
- 社内ツール・小規模・メール+パスワードだけ → 自前(本稿の Flask-Login / JWT)で十分。依存を増やさず、データを自分の DB に持てる。
- SSO / SAML / MFA / コンプライアンスが要件に入った瞬間 → マネージド IdP に寄せる。これらを自前で本番品質に保つのは、認証専門事業者でない限り割に合わない。
- ハイブリッド → 管理画面は自前、顧客向けの認証は IdP、という層別も現実的。
💡 トークンの「意味」を取り違えない:マネージド IdP を使うと、
id_tokenとaccess_tokenという 2 種類の JWT が出てきて混乱しがちです。**ID トークンは「誰がログインしたか(認証の証明)」、アクセストークンは「何にアクセスしてよいか(認可の鍵)」**で、用途が違います。この区別を誤ると「ID トークンを API のアクセス制御に使う」ような設計ミスを犯します。OIDC / OAuth2 におけるこの 2 トークンの正しい使い分けは ID トークン vs アクセストークンの徹底解説 に分けています。IdP を採用するなら、実装前に必ず読んでください。
マネージド IdP の具体的な選定(Cognito / Auth0 / Clerk / Supabase Auth の比較と決め方)は 認証プラットフォーム選定ガイド にまとめています。
まとめと Flask 認証チェックリスト
Flask の認証は、核に無いからこそ「どの方式を、どのクライアントに」という選択から始まります。本稿の要点を再掲します。
- 方式はクライアントで決まる。サーバーレンダリング 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が核。機密操作はfresh_login_required。 - Flask-JWT-Extended はステートレス。
JWT_SECRET_KEYはSECRET_KEYと別の鍵で生成・ローテーション。短命 access + 長命 refresh、additional_claimsでロール、user_lookup_loaderでcurrent_user。 - JWT の即時失効はブロックリストで。ただしステートレスの利点と引き換え。アクセストークンを短命にしてバランスを取る。
- トークンの運び方:API は
Authorization: Bearer、ブラウザは httpOnly Cookie +JWT_COOKIE_CSRF_PROTECT。localStorage は XSS で漏れるので避ける。 - 認証 ≠ 認可。
@login_required/@jwt_required()は「ログイン済みか」しか見ない。「何をしてよいか」は DB / RLS で別途判定する。 - 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 のデータ分離・認可設計ガイド へ。そして Flask 全体の設計対象(構成・コンテキスト・デプロイ・テスト・セキュリティ)との接続は Flask 本番運用ガイド に戻って俯瞰してください。認証は、自分で書くか・マネージドに払うかの判断を含めて、事業の信頼性を支える境界設計です。