# 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: 2026-06-26
- Author: 友田 陽大
- Tags: Python, Flask, 認証, JWT, セキュリティ, バックエンド
- URL: https://tomodahinata.com/en/blog/flask-authentication-flask-login-jwt-extended-guide
- Category: Flask in production
- Pillar guide: https://tomodahinata.com/en/blog/flask-production-guide

## Key points

- The auth method is decided by client type. Server-rendered web → Flask-Login (session); SPA/mobile/service-to-service → Flask-JWT-Extended (token). The two aren't mutually exclusive — you can run both in one app
- Flask-Login is session auth and does NOT hash passwords. werkzeug.security's generate_password_hash/check_password_hash handle that. UserMixin, user_loader, @login_required, and current_user are the core
- Flask-JWT-Extended is stateless JWT auth. JWT_SECRET_KEY is a different key from SECRET_KEY. create_access_token/create_refresh_token, @jwt_required, get_jwt_identity, additional_claims for roles, user_lookup_loader for current_user
- How you carry the JWT: Authorization: Bearer for APIs, httpOnly Cookie + CSRF for browsers. Avoid localStorage tokens — they leak instantly under XSS. Statelessness means you can't revoke, so backstop with a blocklist
- If SSO/SAML, MFA, or compliance are requirements, drop self-built auth and move to a managed IdP (Cognito/Auth0/Clerk). Auth flows are this article; authorization (roles, tenant isolation) is a separate responsibility on the DB/server side

---

## **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](/blog/flask-production-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-Login** — **session auth**. Login state is held in Flask's signed session cookie. Ideal for server-rendered web apps (admin panels, internal tools).
2. **Flask-JWT-Extended** — **token 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](/blog/flask-security-sessions-csrf-secure-cookies-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 type | Recommendation | How auth is held | Why |
|---|---|---|---|
| Server-rendered web (admin panel, internal tools, Jinja) | **Flask-Login** (session) | Server-signed cookie | The 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 key | JWT or key | No 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)](/blog/auth-platform-selection-2026-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.

| Concept | The question | How 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](/blog/multi-tenant-saas-data-isolation-authorization-design-guide).

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 **does** | What Flask-Login does **not** do |
|---|---|
| Stores login state in the session cookie | Hash passwords (→ `werkzeug.security`) |
| Provides the `current_user` proxy | Define the user model (you write it) |
| Protects views with `@login_required` | Persist users to the DB (your ORM) |
| Manipulates the session on login/logout | CSRF protection (→ Flask-WTF) |
| Remember-me (persistent login) cookie | Password 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](/blog/flask-application-factory-blueprints-large-app-structure-guide) and ride the "create unbound → bind with `init_app`" pattern.

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

login_manager = LoginManager()
```

```python
# __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`.

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

from .extensions import db


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

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

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

> 💡 **`generate_password_hash` 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`**.

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

from ..models import User

bp = Blueprint("auth", __name__)


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

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

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

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

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

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](/blog/flask-security-sessions-csrf-secure-cookies-guide).

> 💡 **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.

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


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


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

> ⚠️ **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`

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


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


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

- **`@login_required`** — 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."

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


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


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

- **`@fresh_login_required`** — 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`.

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

> 💡 **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)

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

jwt = JWTManager()
```

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

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

> ⚠️ **`JWT_SECRET_KEY` 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](/blog/aws-cognito-jwt-rs256-verification-jwks-security-guide). From here on, this article proceeds assuming self-issued HS256.

### 4.2 Login: Issue an Access Token + a Refresh Token

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

from ..models import User

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


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

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

- **`identity` 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.

```python
from flask_jwt_extended import jwt_required, get_jwt_identity


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

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

```bash
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."

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


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

Diagramming this lifecycle:

| Step | Token | Lifetime | fresh |
|---|---|---|---|
| Login | issues access + refresh | 15 min / 30 days | access is `fresh=True` |
| Normal request | sends access via `Authorization` | — | — |
| access expires | gets a new access via refresh | 15 min | the new access is `fresh=False` |
| Sensitive operation | requires fresh, so demands **re-login** | — | re-login makes it `fresh=True` |
| refresh expires | re-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`.

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


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


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


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

- **`get_jwt()`** — 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`.

```python
from flask_jwt_extended import current_user, jwt_required


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


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


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

- **`@jwt.user_identity_loader`** — 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.

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


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


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

> ⚠️ **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.

---

## **5. How to Carry the JWT: Bearer Header vs. httpOnly Cookie**

**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

| Location | How it's sent | XSS risk | CSRF risk | Suited for |
|---|---|---|---|---|
| **`Authorization` header** (held in memory) | `Bearer <token>` | Low (JS memory only; not persisted) | None (manual header) | **API / mobile** |
| **httpOnly Cookie** | sent automatically by browser | Low (unreadable from JS) | **Present** → CSRF protection required | **Browser (SPA)** |
| **localStorage** | JS reads it and adds to header | **High** (all stolen under XSS) | None | **Avoid** |

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

### 5.3 For Browsers: httpOnly Cookie + CSRF (double-submit)

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.

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

```python
from flask_jwt_extended import set_access_cookies, create_access_token


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

> 💡 **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](/blog/flask-security-sessions-csrf-secure-cookies-guide).

> ⚠️ **`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**.

| Approach | XSS resistance | CSRF resistance | Required 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 Cookie | Strong (unreadable from JS) | Weak → **CSRF protection required** | `JWT_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.

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

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

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

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

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

    return app
```

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](/blog/flask-security-sessions-csrf-secure-cookies-guide)). **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)](/blog/flask-security-sessions-csrf-secure-cookies-guide) 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.

| Requirement | Self-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 cost | Server costs only | MAU-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 only** → **self-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 requirements** → **lean 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](/blog/id-token-vs-access-token-oidc-oauth2-guide). 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](/blog/auth-platform-selection-2026-cognito-auth0-clerk-supabase).

---

## **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.

| Category | Check item | ✓ |
|---|---|---|
| Method selection | Chose a method matching the client type (Web / SPA / mobile / service-to-service) | ☐ |
| Method selection | Considered an IdP if there are SSO / SAML / MFA / compliance requirements | ☐ |
| Password | Stored with `generate_password_hash`, leaving no plaintext at all | ☐ |
| Password | Not using MD5 / SHA directly yourself (use a salted hash) | ☐ |
| Flask-Login | Pinned `Flask-Login==0.6.3` (0.7.0 is unreleased); `User` inherits `UserMixin` | ☐ |
| Flask-Login | `session.clear()` on login; `logout_user()` + `session.clear()` on logout | ☐ |
| Flask-Login | Validate the `next` parameter against the same host (open-redirect protection) | ☐ |
| Flask-Login | `fresh_login_required` for sensitive operations; harden Remember-me cookie `Secure`/`HttpOnly` | ☐ |
| Flask-Login | Made login errors vague (user-enumeration protection) | ☐ |
| JWT | Set `JWT_SECRET_KEY` separately from `SECRET_KEY`, not deploying with a default | ☐ |
| JWT | access is short-lived (~15 min), refresh is a separate type (`@jwt_required(refresh=True)`) | ☐ |
| JWT | Implemented a blocklist (`token_in_blocklist_loader`) if instant revocation is a requirement | ☐ |
| JWT carriage | Browsers use httpOnly Cookie + `JWT_COOKIE_SECURE` + `JWT_COOKIE_CSRF_PROTECT` | ☐ |
| JWT carriage | Not storing the token in localStorage (XSS protection) | ☐ |
| Authorization | Not conflating `@login_required` / `@jwt_required()` with authorization; deciding separately in DB / RLS | ☐ |
| Surrounding | Rate 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](/blog/multi-tenant-saas-data-isolation-authorization-design-guide). And to connect with Flask's overall design subjects (configuration, context, deployment, testing, security), return to the [Flask Production Operations Guide](/blog/flask-production-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.
