Skip to main content
友田 陽大
Flask in production
Python
Flask
認証
JWT
セキュリティ
バックエンド

A Guide to Implementing Authentication in Flask: When to Use Flask-Login (Session Auth) vs. Flask-JWT-Extended (Token Auth), and How to Build Both for Production

A production-quality guide to Flask authentication. From the decision axis for choosing between Flask-Login (0.6.3) session auth and Flask-JWT-Extended (4.7.4) token (JWT) auth per client type, through register/login/logout, @login_required, current_user, refresh tokens, blocklists, and httpOnly Cookie + CSRF — explained with real code faithful to the official docs. It also honestly maps out the boundary between rolling your own auth and using a managed IdP.

Published
Reading time
30 min read
Author
友田 陽大
Share
Contents

Introduction: Because Flask Has No Auth "In the Core," You Choose the Method First

Flask has no built-in authentication. This is the natural consequence of the design philosophy I kept returning to in the Flask Production Operations Guide: "Flask provides only the core (routing, request/response, templating, configuration, context) and loads neither an ORM, nor forms, nor auth." That's precisely why authentication begins with a design decision: which extension do you load, for which client?

There are essentially two choices.

  1. Flask-Loginsession auth. Login state is held in Flask's signed session cookie. Ideal for server-rendered web apps (admin panels, internal tools).
  2. Flask-JWT-Extendedtoken auth. Login state is held in a JWT (JSON Web Token); the server holds no state. Ideal for SPAs, mobile, and service-to-service APIs.

This article is a sister piece to the Flask Security Implementation Guide. Where that one covered how to harden the boundary — "what a session cookie really is, SECRET_KEY, CSRF, XSS" — this one covers the login flow itself: who is logged in, how they log in and out, and how you protect a guarded endpoint. The mechanics of cookie signing and CSRF are left to that article and not re-explained here.

I designed and built the backend of an economic-ministry-award-winning B2B SaaS in Python / Flask / SQLAlchemy / PostgreSQL, running a server-rendered admin panel (Flask-Login) and a JSON API (JWT) side by side in a single app in production. At the same time, on a separate project, I've also implemented a managed IdP like AWS Cognito / OIDC. That's why this article will honestly write about "the cases where you should NOT write your own auth" too. Authentication is an area where rolling your own is not always the right answer.

💡 Versions covered in this article: Flask-Login==0.6.3 (the latest installable stable version. The "latest" in the docs shows 0.7.0, but that is the unreleased main branch — always pin 0.6.3) and Flask-JWT-Extended==4.7.4. Flask itself is assumed to be in the 3.1 line. The code is based on both extensions' official documentation.


1. The First Decision: Session Auth or Token Auth?

The choice of auth method is decided not by preference but by client type. There is exactly one axis: "Does the server hold login state in a cookie (session), or does the client hold it in a token (stateless)?"

Client typeRecommendationHow auth is heldWhy
Server-rendered web (admin panel, internal tools, Jinja)Flask-Login (session)Server-signed cookieThe browser sends the cookie automatically. A mature approach with CSRF protection built in. No token management needed
SPA (React / Vue, cross-origin or same-origin)Flask-JWT-Extended (token)JWT (cookie or header)You want the API stateless. As shown below, Cookie+CSRF is also an option
Mobile app (iOS / Android)Flask-JWT-Extended (token)JWT (header)No cookie store. Authorization: Bearer is natural
Service-to-service (microservices / batch)Flask-JWT-Extended or API keyJWT or keyNo browser involved. You want to avoid sharing state

💡 Session auth isn't "old," it's "mature". You often see the claim "JWT is new and modern, sessions are old," but this is wrong. For server-rendered web apps, session auth is still the first choice. The browser carries the cookie automatically, you can revoke immediately on the server side (which, as shown later, is hard with JWT), and as long as you handle CSRF it's robust. A design that adopts JWT "just because" and parks it in the browser's localStorage merely takes on the XSS risk discussed below — a downgrade. "SPA / mobile → JWT," "server-rendered → session" — this straightforward correspondence is the baseline.

1.1 The Two Are Not Mutually Exclusive

An important fact: Flask-Login and Flask-JWT-Extended can coexist in a single app. In an actual B2B SaaS, the canonical structure is:

  • /admin/* (internal operators' admin panel) → server-rendered + Flask-Login (session)
  • /api/v1/* (JSON API hit by the customer's SPA / mobile) → Flask-JWT-Extended (JWT)

You split the routes with Blueprints and apply a different auth to each. The concrete combined setup is shown in §6.

1.2 Should You Even Write Your Own Auth — The Third Path of a Managed IdP

Pause here for a moment. Both Flask-Login and Flask-JWT-Extended are tools for "implementing authentication yourself." You end up managing password hashes, login forms, token issuance, and revocation in your own code and DB. This is appropriate in many cases, but there are cases where it isn't.

If any of the following apply, don't write your own auth — adopt a managed IdP (AWS Cognito / Auth0 / Clerk / Supabase Auth).

  • SSO / SAML / enterprise IdP integration is a requirement (enterprise customers want to log in with Okta / Entra ID)
  • You need to provide robust MFA (multi-factor authentication)
  • You support many social logins (Google / GitHub / Apple)
  • You don't want to shoulder compliance (SOC2 / breached-password monitoring / suspicious-login detection) yourself

These are not "adding an auth feature" — they are "a business of maintaining authentication forever." If you roll your own, it expands without limit: password reset, email verification, rate limiting, breached-password checks, MFA, audit logs... Unless you are an auth specialist business yourself, paying a managed service here is the rational move. The details of this decision are split into A Guide to Choosing an Auth Platform (Cognito / Auth0 / Clerk / Supabase).

⚠️ The hidden cost of self-built auth: an estimate of "just building a login feature" almost always falls apart. Production self-built auth comes bundled with password hashing (§3.3), brute-force protection (rate limiting / lockout), session-fixation protection (§3.7), password-reset token management, email verification, rejecting breached passwords... This article shows how to implement these in Flask, but first ask yourself "am I prepared to shoulder all of this?" If you're not, it's an IdP. The knowledge here also pays off even when you use an IdP — as an understanding of "what the IdP is doing behind the scenes."


2. Separating Authentication (AuthN) from Authorization (AuthZ)

Before we get into implementation, let's strictly separate two concepts that often get conflated. This clarifies the scope of this article.

ConceptThe questionHow this article treats it
Authentication (AuthN)Verifies "who are you"The main subject (login, token issuance, establishing current_user)
Authorization (AuthZ)Verifies "what are you allowed to do"This article goes only as far as the entrance (role checks). The main body is a separate article

What this article establishes is up to who current_user is (the currently logged-in user). Whether that current_user "may update this resource of this tenant" — that authorization is a separate responsibility from authentication, decided by server-side logic and the DB (such as PostgreSQL RLS).

⚠️ @login_required / @jwt_required() are NOT authorization. They only check "is the user logged in (i.e., authenticated)?" Whether "this user may access this data" must be decided separately, inside the view, by cross-checking against the DB. The fact that session['user_id'] or a JWT's sub can't be tampered with, and the fact that that ID is the owner of the resource in question, are entirely different guarantees. If you pass auth but forget authorization, you get broken access control where someone else's data is visible (IDOR). The design of authorization and data isolation in a multi-tenant environment is split into A Guide to Data Isolation & Authorization Design for Multi-Tenant SaaS.

In §4.5 this article does show "a minimal role-based authorization hook (JWT claims + a custom decorator)," but that is no more than a thin entrance layered on top of the auth info. For full-fledged authorization, see the article above.


3. Production Implementation of Flask-Login (Session Auth)

We'll implement authentication for a server-rendered web app with Flask-Login. First, nail down the limit of its role: what Flask-Login manages is only "login state."

3.1 What Flask-Login "Does" and "Does Not Do"

What Flask-Login doesWhat Flask-Login does not do
Stores login state in the session cookieHash passwords (→ werkzeug.security)
Provides the current_user proxyDefine the user model (you write it)
Protects views with @login_requiredPersist users to the DB (your ORM)
Manipulates the session on login/logoutCSRF protection (→ Flask-WTF)
Remember-me (persistent login) cookiePassword reset / email verification

In short, Flask-Login is the "auth-state management layer"; you do password verification and storage yourself (with werkzeug.security and your ORM). Without understanding this separation, you fall into the fatal misconception that "Flask-Login stores passwords securely for me."

3.2 Setup: init_app in the Factory

Like other extensions, align with the application factory and ride the "create unbound → bind with init_app" pattern.

# extensions.py — the "bare" extension, not bound to any app
from flask_login import LoginManager

login_manager = LoginManager()
# __init__.py — bound inside the application factory
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))

Three settings to nail down.

  • user_loader — the heart of Flask-Login. On every request, it restores the User object from the user_id in the session. This supplies the substance behind current_user. db.session.get(User, ...) is SQLAlchemy 2.x's primary-key fetch API.
  • login_manager.login_view = "auth.login" — when an unauthenticated request hits a @login_required view, it redirects here (the value is an endpoint name including the Blueprint name).
  • login_manager.session_protection = "strong" — when the session's IP / User-Agent changes, "strong" destroys the session and requires re-authentication. This mitigates session hijacking.

3.3 The User Model: UserMixin and Password Hashing

The User model inherits from UserMixin. This automatically gives it the four attributes/methods Flask-Login needs — is_authenticated / is_active / is_anonymous / get_id().

Never store passwords in plaintext — always hash them. Flask-Login doesn't do this, so use Werkzeug's 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 is safe, salt included. werkzeug.security.generate_password_hash internally generates a salt and hashes with a sufficiently strong algorithm by default (varies by version, e.g. scrypt). Do not use MD5 / SHA-256 directly yourself — those are too fast (weak against brute force) and lack a salt. Hash with generate_password_hash(pw) and verify with check_password_hash(hash, pw) — remember just these two and you've covered the basics of password storage. For stricter requirements you'd separately consider argon2, but Werkzeug's default is plenty for the vast majority of uses.

⚠️ is_active collides easily with a column name. UserMixin provides is_active as a property that returns True. If you have a column representing soft-deletion or deactivation, either give it a distinct name like is_active_flag as above, or override the is_active property. A user for whom is_active returns False cannot log in even with login_user() (Flask-Login refuses). You can ride account deactivation on this mechanism.

3.4 Login: Verify → login_user → Regenerate the Session

Here's the login view implementation. Keep the order verify password → regenerate session → 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")

Three production points.

  • login_user(user) writes user.get_id() into the session. On subsequent requests, §3.2's user_loader restores the User from this ID and supplies it as current_user.
  • Keep the error message vague: "email or password is incorrect." Distinguishing "the email address does not exist" from "the password is wrong" tells an attacker "which emails are registered" — user enumeration.
  • Call session.clear() right before login. This protects against session-fixation attacks. For details, see Security article §3.2.

💡 Crush timing attacks with "the same branch" too: the code above merges user-absent and password-mismatch into a single branch, but strictly speaking, if you don't call check_password when the user is absent, the difference in response time can leak "whether the email exists." For highly sensitive apps, run check_password_hash against a dummy hash even when the user is absent, to equalize response time. Whether you go this far depends on your threat model, but keep the principle "don't leak existence" consistent.

3.5 The next Parameter: Closing Off Open Redirects

When @login_required redirects to the login screen, it appends the original destination like ?next=/dashboard. Returning the user there after login is good UX, but redirecting on next without validating it becomes an "open redirect" vulnerability where ?next=https://evil.example.com sends them to an external site.

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")  # 不正/未指定なら既定の安全な遷移先

⚠️ Don't write return redirect(request.args.get("next")) directly. That's a textbook open-redirect vulnerability. Since next is external input, always validate that it's a relative path within the same host before using it. Rejecting via an allowlist (same netloc) is the reliable approach. Close it before it gets used as a phishing springboard.

3.6 Protection, Logout, and 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 — redirects unauthenticated access to login_view.
  • current_user — a proxy referenceable from anywhere. Usable in views and inside Jinja templates ({{ current_user.email }}) too. When unauthenticated, it points to AnonymousUserMixin (is_authenticated == False).
  • Logout is logout_user() + session.clear(). The former clears Flask-Login's state; the latter discards any residual keys along with it.

3.7 Re-Authentication for Sensitive Operations: fresh_login_required

"Logged in" and "the person just entered their password" are different trust levels. For sensitive operations like password change, email change, account deletion, or payment-info change, you should not trust a session that merely came back via the Remember-me cookie.

Flask-Login handles this with the concept of a "fresh session." A session right after login_user() is "fresh"; a session restored from the Remember-me cookie is "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 — stricter than @login_required; it refuses unless the session is fresh. A user who merely came back via Remember-me is asked to re-authenticate (confirm_login()) here.
  • confirm_login() — call it after a successful password re-entry to make the session fresh again. Now they can proceed to the sensitive operation.

3.8 Customizing Unauthenticated Behavior: unauthorized_handler

login_view returns a redirect, but for an API you may want to return a JSON 401. You can swap the behavior with @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))

💡 A roundup of Flask-Login config keys: login_manager.login_view (redirect target), session_protection ("basic" / "strong" / None), REMEMBER_COOKIE_DURATION (Remember-me lifetime), REMEMBER_COOKIE_SECURE / REMEMBER_COOKIE_HTTPONLY (Remember-me cookie attributes — since this is a separate cookie from the session cookie, these too must be hardened with Secure / HttpOnly in production). Forgetting to tighten the Remember-me cookie's attributes is a common oversight.


4. Production Implementation of Flask-JWT-Extended (Token Auth)

We'll implement stateless token auth for SPAs, mobile, and service-to-service APIs. Whereas Flask-Login holds state in a server-side session, with JWT the client holds the state (a signed token) and the server merely verifies it — this is the source of both scalability and the difficulty of revocation discussed later.

4.1 JWTManager and JWT_SECRET_KEY (Different from 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 is a different key from Flask's SECRET_KEY. Flask-JWT-Extended uses JWT_SECRET_KEY to sign JWTs (falling back to SECRET_KEY if unset). You should deliberately set a separate key. The reason is separation of responsibilities — keeping the session-signing key and the JWT-signing key separate localizes the damage if one leaks, and lets you rotate them independently. Generate both with python -c 'import secrets; print(secrets.token_hex())' and inject them from environment variables. Deploying with a default value like "super-secret" is out of the question — anyone can forge tokens.

💡 HS256 or RS256?: the above is a symmetric-key (HS256) setup, which is plenty for a single self-contained service. On the other hand, when the side issuing the token and the side verifying it are different (e.g., Flask verifies a JWT issued by Cognito), it becomes public-key verification via asymmetric keys (RS256) + JWKS. The design for verifying third-party-issued JWTs in Flask is split into A Guide to Verifying Cognito JWTs with RS256 + JWKS. From here on, this article proceeds assuming self-issued HS256.

4.2 Login: Issue an Access Token + a Refresh Token

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 must be JSON-serializable. You can't pass the user object as-is. By convention you pass str(user.id) (the stringified primary key). This becomes the JWT's sub (subject) claim.
  • fresh=True — the same concept as Flask-Login's §3.7. The access token right after login is "fresh"; tokens re-issued by refresh are (as shown later) "non-fresh." Sensitive operations can require a fresh token.

4.3 Protected Endpoints: @jwt_required and get_jwt_identity

A protected endpoint faithful to the official minimal form.

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

The client attaches the Authorization: Bearer <access_token> header to protected requests.

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

4.4 Refresh Tokens: Short-Lived Access + Long-Lived Refresh

The heart of JWT design is the two-tier setup of access tokens and refresh tokens.

  • Access token — short-lived (around 15 minutes). Attached to every request; minimizes the exposure window on leakage.
  • Refresh token — long-lived (around 30 days). Usable only to issue a new access token. In the official words, "a refresh token is a long-lived JWT that can only be used to create new access tokens."
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)

Diagramming this lifecycle:

StepTokenLifetimefresh
Loginissues access + refresh15 min / 30 daysaccess is fresh=True
Normal requestsends access via Authorization
access expiresgets a new access via refresh15 minthe new access is fresh=False
Sensitive operationrequires fresh, so demands re-loginre-login makes it fresh=True
refresh expiresre-login required

💡 What @jwt_required(refresh=True) means: this decorator creates an endpoint that only a refresh token can pass. Hit /refresh with an access token and it's rejected; conversely, hit a normal API (@jwt_required()) with a refresh token and it's rejected too. Because the token types are separated, you get a double safety valve: "even if the access token leaks, it can't be used to refresh," and "even if the refresh token leaks, it can't hit the API directly." Combine it with jwt_required()'s fresh=True argument and you can require fresh only for sensitive operations.

4.5 Role Authorization: additional_claims + a Custom Decorator

Embed a role in the token to use as the entrance to authorization (full-fledged authorization is a separate article, per §2). Add a claim with create_access_token's 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() — returns all claims of the current token as a dict. You read the roles embedded via additional_claims here.
  • Note that claim roles "don't revoke instantly". Even if you strip a user's role after issuing the token, existing tokens keep the old role until they expire. This is another reason to make access tokens short-lived. If instant revocation is a requirement, combine it with a blocklist (§4.7).

4.6 Using current_user with JWT Too: user_lookup_loader

Like Flask-Login's current_user, you may want to touch the user object directly with JWT too — achieve this with @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 — lets you pass the user object directly to create_access_token(identity=user), converting it to str(user.id) internally.
  • @jwt.user_lookup_loader — fetches the User from the token and supplies it to flask_jwt_extended.current_user. This gives you the same writing feel as Flask-Login. Just understand that it hits the DB on every request, so some of the stateless advantage is diminished (use it only on the APIs that need it, cache, etc.).

4.7 Token Revocation: Backstop the Stateless Weakness with a Blocklist

JWT's biggest weakness is "you can't revoke it instantly on the server side." With session auth you can log someone out instantly by deleting the server-side session, but a JWT is valid until expiry, making logout or "invalidate now because it's suspicious" fundamentally hard.

The blocklist (a.k.a. denylist) pattern backstops this. Record the jti (JWT ID) of tokens you want to revoke in Redis or similar, and check it on every request.

# 失効済み 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")

⚠️ A blocklist breaks JWT's "pure statelessness". Once you introduce a blocklist, you check Redis (or similar) on every request, so some of JWT's "the server holds no state" advantage is lost. There's an essential trade-off here — "fully stateless (can't revoke)" vs. "can revoke instantly (requires a stateful lookup)." Most production APIs choose the latter, making access tokens short-lived (a few minutes to 15) to balance lookup cost against security. Design with the understanding that "JWT means stateless AND perfect revocation" cannot both hold.


Where you put the JWT and how you send it is a design decision tied directly to security. JWT_TOKEN_LOCATION defaults to ["headers"], but you can also choose "cookies" / "json" / "query_string".

5.1 The Three Options and Their Risk Levels

LocationHow it's sentXSS riskCSRF riskSuited for
Authorization header (held in memory)Bearer <token>Low (JS memory only; not persisted)None (manual header)API / mobile
httpOnly Cookiesent automatically by browserLow (unreadable from JS)Present → CSRF protection requiredBrowser (SPA)
localStorageJS reads it and adds to headerHigh (all stolen under XSS)NoneAvoid

5.2 Why localStorage Is Dangerous

"It's an SPA, so I'll put the JWT in localStorage" — this is the most widespread anti-pattern.

⚠️ A token in localStorage leaks instantly under XSS. localStorage is freely readable from JavaScript. If there's a single XSS hole anywhere in your app (your own code, or a third-party npm package), an injected script can steal the token with localStorage.getItem('token') and send it to the attacker's server. An httpOnly cookie is unreadable from JS, so even if XSS occurs, the token itself isn't stolen (CSRF protection is needed separately). You sometimes see the claim "localStorage is safer than a session cookie," but for XSS resistance the httpOnly cookie is clearly superior. Even in an SPA, put the token in an httpOnly cookie, or design it to hold the token in memory (a JS variable) and re-fetch on page reload.

If you use JWT in a browser SPA, the correct answer is to put it in an httpOnly cookie and protect against CSRF. Flask-JWT-Extended supports cookie mode out of the box.

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

💡 What double-submit CSRF is: with JWT_COOKIE_CSRF_PROTECT=True, Flask-JWT-Extended puts the JWT body in an httpOnly cookie and the CSRF value in a separate (JS-readable) csrf_access_token cookie. On state-changing requests, the client echoes that value back in the X-CSRF-TOKEN header. The server verifies "does the cookie's CSRF value match the header's value?" An attacker's site can't read the cookie (same-origin policy), so it can't attach the correct header, and CSRF doesn't succeed. For the principle of CSRF itself, see Security article §4.

⚠️ JWT_COOKIE_SECURE must be True in production. The official docs explicitly state "should always be set to True in production." Leave it False and the token-bearing cookie is sent over plaintext HTTP and can be sniffed by a man-in-the-middle. Note that query_string (token in the URL query) is also not recommended by the official docs, because the URL remains in browser history, proxy logs, and the Referer. Don't use it.

5.4 Facing the XSS vs. CSRF Trade-off Head-On

The header approach and the cookie approach have an essential trade-off: they are strong/weak against different attacks.

ApproachXSS resistanceCSRF resistanceRequired protection
Authorization header (memory)Somewhat strong (not persisted)Strong (browser doesn't send it automatically)Don't let XSS happen in the first place (CSP, escaping)
httpOnly CookieStrong (unreadable from JS)Weak → CSRF protection requiredJWT_COOKIE_CSRF_PROTECT + SameSite

There's no single answer to "which is safer." Mobile / external API → header, browser SPA → httpOnly cookie + CSRF — that's the practical landing point. What's common to both is: never choose localStorage — because it's the most vulnerable to XSS.


6. A Production Example: Running Flask-Login and JWT Together in One App

Integrating everything so far, here's the combined "server-rendered admin panel (Flask-Login) + JSON API (JWT)" setup actually used in a B2B SaaS.

# __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

Design points.

  • The admin panel (/admin/*) is Flask-Login + CSRFProtect. Since they're browser forms, CSRF protection is required.
  • The API (/api/v1/*) is JWT, with csrf.exempt. Since Authorization: Bearer header auth isn't sent automatically by the browser, the premise of CSRF collapses (as detailed in Security article §4.5). However, when carrying the JWT in cookie mode, CSRF protection IS needed — handle that separately with JWT_COOKIE_CSRF_PROTECT (§5.3).
  • The User model is shared. Both auths use the same users table and the same check_password. The auth "method" differs, but the place that stores "who" is one.

6.1 The Surrounding Hooks That Production Requires

Around the auth flow, you need defenses that auth alone doesn't provide.

  • Rate limiting / lockout for login attempts — stop consecutive failures against the same IP / same account at a threshold to prevent brute force. Implement it with Flask-Limiter or similar, and combine it with Flask Security article §7 (DoS and resource limits) for protection against oversized input.
  • Password reset — issue an expiry-bound, signed token with itsdangerous.URLSafeTimedSerializer and send it by email (a design that doesn't reinvent the session — see Security article §1.3).
  • Email verification — likewise with a signed token.
  • Audit logs — record login success/failure and password changes (keep the principle of not logging PII / secrets).

💡 Avoid "auth works but operations fall apart": only once you've written all the surrounding hooks above does self-built auth reach production quality. If, seeing the volume of these, you feel it's "heavy," that may be the "sign you should use a managed IdP" of §1.2. My B2B SaaS found self-built Flask-Login sufficient for the internal admin panel (few users, no SSO needed), but once it came time to offer SSO / MFA to customers, that layer leaned on an IdP — that's the split I make.


7. Self-Built Auth vs. Managed IdP: Drawing the Boundary Honestly

Finally, let me gather the "should you write your own?" judgments scattered throughout into a single table. This is the heart of technology selection.

RequirementSelf-built (Flask-Login / JWT)Managed IdP (Cognito / Auth0 / Clerk)
Email + password login◎ Easy
Social login (Google, etc.)△ Implement each provider yourself◎ Config only
SSO / SAML (enterprise IdP integration)✕ Implementation hell◎ This is its forte
MFA (multi-factor authentication)△ Heavy to roll your own◎ Built in
Breached-password / suspicious-login detection✕ Unrealistic to roll your own
Shouldering compliance (SOC2, etc.)✕ You carry it yourself
Avoiding vendor lock-in△ Migration cost
Monthly costServer costs onlyMAU-based (gets expensive at scale)
Data sovereignty (user info in your own DB)△ Provider-dependent

The decision guideline is simple.

  1. Internal tools / small scale / email+password onlyself-built (this article's Flask-Login / JWT) is plenty. No added dependencies; you keep the data in your own DB.
  2. The moment SSO / SAML / MFA / compliance enters the requirementslean on a managed IdP. Keeping these at production quality yourself is not worth it unless you're an auth specialist business.
  3. Hybrid → admin panel self-built, customer-facing auth via an IdP — layering by use is also realistic.

💡 Don't mistake the "meaning" of tokens: when you use a managed IdP, two kinds of JWT — id_token and access_token — show up and easily cause confusion. The ID token is "who logged in (proof of authentication)," the access token is "what you may access (the authorization key)" — different purposes. Get this distinction wrong and you commit design mistakes like "using the ID token for API access control." The correct usage of these two tokens in OIDC / OAuth2 is split into A Thorough Explanation of ID Token vs. Access Token. If you adopt an IdP, read it before implementing.

The concrete selection of a managed IdP (comparison and how to decide among Cognito / Auth0 / Clerk / Supabase Auth) is gathered in A Guide to Choosing an Auth Platform.


Summary and a Flask Auth Checklist

Flask authentication, precisely because it's not in the core, begins with the choice of "which method, for which client." Here are the key points of this article, restated.

  1. The method is decided by the client. Server-rendered web → Flask-Login (session); SPA / mobile / service-to-service → Flask-JWT-Extended (token). The two can coexist in one app.
  2. Flask-Login is session management only. Password hashing is werkzeug.security's generate_password_hash / check_password_hash. UserMixin + user_loader + @login_required + current_user are the core. Sensitive operations use fresh_login_required.
  3. Flask-JWT-Extended is stateless. Generate and rotate JWT_SECRET_KEY as a separate key from SECRET_KEY. Short-lived access + long-lived refresh, roles via additional_claims, current_user via user_lookup_loader.
  4. Instant JWT revocation is via a blocklist. But at the cost of the stateless advantage. Balance it by making access tokens short-lived.
  5. How to carry the token: APIs use Authorization: Bearer, browsers use an httpOnly Cookie + JWT_COOKIE_CSRF_PROTECT. Avoid localStorage — it leaks under XSS.
  6. Authentication ≠ authorization. @login_required / @jwt_required() only check "is the user logged in?" "What they may do" is decided separately in the DB / RLS.
  7. If SSO / SAML / MFA / compliance are requirements, drop self-built and go managed IdP. Estimate it as "a business of maintaining auth forever," not "a login feature."

Here's the pre-production checklist — the items I actually verify, organized.

CategoryCheck item
Method selectionChose a method matching the client type (Web / SPA / mobile / service-to-service)
Method selectionConsidered an IdP if there are SSO / SAML / MFA / compliance requirements
PasswordStored with generate_password_hash, leaving no plaintext at all
PasswordNot using MD5 / SHA directly yourself (use a salted hash)
Flask-LoginPinned Flask-Login==0.6.3 (0.7.0 is unreleased); User inherits UserMixin
Flask-Loginsession.clear() on login; logout_user() + session.clear() on logout
Flask-LoginValidate the next parameter against the same host (open-redirect protection)
Flask-Loginfresh_login_required for sensitive operations; harden Remember-me cookie Secure/HttpOnly
Flask-LoginMade login errors vague (user-enumeration protection)
JWTSet JWT_SECRET_KEY separately from SECRET_KEY, not deploying with a default
JWTaccess is short-lived (~15 min), refresh is a separate type (@jwt_required(refresh=True))
JWTImplemented a blocklist (token_in_blocklist_loader) if instant revocation is a requirement
JWT carriageBrowsers use httpOnly Cookie + JWT_COOKIE_SECURE + JWT_COOKIE_CSRF_PROTECT
JWT carriageNot storing the token in localStorage (XSS protection)
AuthorizationNot conflating @login_required / @jwt_required() with authorization; deciding separately in DB / RLS
SurroundingRate limiting / lockout for login attempts; password reset via signed token

Authentication is the work of establishing "who"; authorization is the work of establishing "what you may do." Once you've correctly established current_user in this article, the next level up — the design of authorization where "an authenticated user can access only the data they're allowed to" — goes to A Guide to Data Isolation & Authorization Design for Multi-Tenant SaaS. And to connect with Flask's overall design subjects (configuration, context, deployment, testing, security), return to the Flask Production Operations Guide for the bird's-eye view. Authentication — including the judgment of whether to write it yourself or pay for managed — is boundary design that underpins the trustworthiness of your business.

友田

友田 陽大

Developer of a METI Minister's Award–winning product. With TypeScript + Python + AWS, I deliver SaaS, industry DX, and production-grade generative AI (RAG) end to end — from requirements to infrastructure and operations — single-handedly.

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.

Available for both project-based (contract) and advisory engagements. Start with a free 30-minute consult.

Also worth reading