# Flask のセキュリティ実装ガイド（3.1系）：署名Cookieセッション・SECRET_KEY・安全なCookie・CSRF・XSS自動エスケープ・セキュリティヘッダ

> Flask 3.1系のセキュリティ境界を本番品質で固める実装ガイド。クライアント側署名Cookieであるsessionの正体、SECRET_KEYと鍵ローテーション、SECURE/HttpOnly/SameSiteの安全なCookie、Flask-WTFのCSRFProtect、Jinjaの自動エスケープ、HSTS/CSP/nosniffのセキュリティヘッダ、DoS対策までを公式ドキュメントに忠実な実コードで解説します。

- 公開日: 2026-06-21
- 著者: 友田 陽大
- タグ: Python, Flask, セキュリティ, CSRF, Cookie, XSS, バックエンド
- URL: https://tomodahinata.com/blog/flask-security-sessions-csrf-secure-cookies-guide

## 要点

- Flaskのsessionはクライアント側の署名付きCookie。ItsDangerousで署名するため改ざんは検知できるが中身は読める——『完全性』は守るが『機密性』は守らない。秘密や大きなデータはサーバー側セッションへ
- SECRET_KEYはセッション署名の要。secrets.token_hex()で生成し環境変数で注入する。3.1で追加のSECRET_KEY_FALLBACKSで、旧鍵の署名を検証しつつ無停止で鍵ローテーションできる
- 本番CookieはSECURE/HttpOnly/SameSite='Lax'で固める。ログイン時のsession.clear()+再生成でセッション固定攻撃を、PERMANENT_SESSION_LIFETIMEでリプレイ攻撃を緩和する
- CSRFはFlask核に無い（公式の設計判断）。Flask-WTFのCSRFProtectをファクトリでinit_appし、テンプレートトークンとAJAXのX-CSRFTokenヘッダで守る。ステートレスなBearerトークンAPIにCSRFは原理的に不要
- XSSはJinjaの自動エスケープ（.html等）が既定の防壁。escape/Markupはmarkupsafe由来。セキュリティヘッダ（HSTS/CSP/nosniff/frame-options）は既定で付かないのでafter_requestかFlask-Talismanで付与する

---

## **導入：Flask のセキュリティは「賢い既定 + あなたの選択」**

Flask のセキュリティを語るとき、最初に直さなければならない誤解が 2 つあります。

1. **「`session` に入れた値は秘密だ」** ——違います。Flask の `session` は**クライアント側の署名付き Cookie** です。署名で改ざんは検知できますが、**中身はユーザーが読めます**。
2. **「フレームワークが CSRF や XSS を全部守ってくれる」** ——半分だけ正しい。XSS は Jinja の自動エスケープが既定で守りますが、**CSRF は Flask 核に存在しません**。セキュリティヘッダも既定では一切付きません。

この記事は、[Flask 本番運用ガイド](/blog/flask-production-guide) の §7（セキュリティ）を、本番品質で実装するための深掘り（スポーク）です。扱うのは「Flask が既定で守ってくれること」と「あなたが明示的に固めなければならない境界」の正確な切り分けと、その実装です。

筆者は、**経済産業大臣賞を受賞した B2B SaaS のバックエンドを Python / Flask / SQLAlchemy / PostgreSQL で設計・実装し、マルチテナント構成で本番運用**してきました。テナント間でデータが混ざれば事業が終わるという緊張感の中で、ここに書く境界設計を一つずつ固めてきました。本稿のコードは、その実戦と Flask 3.1 系の公式ドキュメントに基づきます。

> 💡 **この記事で扱うバージョン**：**Flask 3.1 系**を前提とします。Flask 3.1 では `SECRET_KEY_FALLBACKS`（鍵ローテーション）・`SESSION_COOKIE_PARTITIONED`（CHIPS）・`MAX_FORM_MEMORY_SIZE` / `MAX_FORM_PARTS`（DoS 緩和）・`TRUSTED_HOSTS`（Host ヘッダ検証）といった、本番堅牢化に直結する設定が追加されています。本稿はそれらを順に扱います。

---

## **1. Flask のセッション：それは「署名付きクライアント Cookie」である**

### 1.1 何を守り、何を守らないのか

まず Flask の `session` の正体を、公式の言葉で正確に押さえます。クイックスタートはこう述べています——「セッションは Cookie の上に実装されており、Cookie を暗号学的に**署名**する。ユーザーは Cookie の中身を**見ることはできる**が、署名に使った秘密鍵を知らない限り**変更はできない**」。署名の実装は依存ライブラリの **ItsDangerous** です。

ここから導かれる結論は、セキュリティ設計上きわめて重要です。

| 性質 | Flask の `session` Cookie | 意味 |
|---|---|---|
| **完全性（Integrity）** | ✅ 守る | 署名により改ざんを検知できる。`session['role']='admin'` への書き換えは弾かれる |
| **機密性（Confidentiality）** | ❌ 守らない | 中身は Base64 で誰でもデコードして**読める**（暗号化ではない） |
| **真正性（Authenticity）** | ✅ 守る | 秘密鍵を持つサーバーが署名したことを保証する |

使い方そのものは素直です。`session` は dict のように振る舞います。

```python
from flask import session

# 書き込み（レスポンスで署名付き Cookie として送られる）
session['username'] = request.form['username']

# 読み取り
user = session.get('username')

# 削除
session.pop('username', None)
```

### 1.2 「読める」がもたらす設計の鉄則

`session` の中身が読めるという事実から、運用上の鉄則が 2 つ出ます。

- **`session` に秘密を入れない**。API キー・パスワード・他人に見られて困る個人情報・内部フラグの「生値」を `session` に格納してはいけません。入れてよいのは「ユーザー ID」のような、**漏れても署名がある限り害が小さい識別子**だけです。実データは DB から ID で引きます。
- **権限判定は `session` の値だけに依存しない**。`session['user_id']` は改ざんできませんが、その ID が「いまこのテナントのこのリソースにアクセスしてよいか」は別問題です。認可は DB の状態と突き合わせて判定します（後述の[マルチテナント認可設計](/blog/multi-tenant-saas-data-isolation-authorization-design-guide)に分けています）。

> ⚠️ **最頻出の誤解**：「`session` は暗号化されているから機密を入れて良い」。これは誤りです。署名（signing）と暗号化（encryption）は別物で、Flask の既定は**署名のみ**です。`session` Cookie をブラウザの開発者ツールで取り出し、Base64 デコードすれば JSON が読めます。秘密を Cookie に乗せたいなら、暗号化込みの仕組みか、そもそもサーバー側セッションへ移します。

### 1.3 いつサーバー側セッションへ移すか

クライアント側 Cookie セッションには、もう一つ実務的な制約があります。**Cookie のサイズ上限**です。Flask は `MAX_COOKIE_SIZE`（既定 4093 バイト）を超えると警告を出します。ブラウザの Cookie サイズ上限（おおむね 4KB）に起因する制約で、これを超えるとセッションが**黙って壊れる**（Cookie が送られない）ことがあります。

次のいずれかに当てはまるなら、クライアント Cookie ではなく**サーバー側セッション**（`Flask-Session` 拡張など。Redis / DB / ファイルにセッション本体を保存し、Cookie には ID だけを乗せる）を検討します。

- セッションに入れたいデータが数 KB を超える（カート・ウィザードの途中状態など）。
- 機密データをセッションに保持する必要があり、クライアントに渡したくない。
- 「サーバー側から特定ユーザーのセッションを即時失効させたい」（クライアント Cookie はサーバーから無効化しづらい）。

> 💡 **「署名付き Cookie」の応用**：セッション以外のデータに署名付き Cookie や署名付きトークン（パスワードリセットリンクなど）を使いたいときは、Flask が内部で使っているのと同じ **`itsdangerous`** を直接使えます。`itsdangerous.URLSafeTimedSerializer` で「有効期限付き・署名付きトークン」を発行・検証できます。`session` を再発明せず、同じ基盤を再利用するのが筋の良い設計です。

---

## **2. SECRET_KEY：すべての署名の根**

### 2.1 なぜ必須なのか、どう生成するのか

`session` の署名は `SECRET_KEY` で行われます。これが**未設定だとセッションは使えず**、**弱い／漏れると署名を偽造され、`session['role']='admin'` のような改ざんが通ってしまいます**。`SECRET_KEY` は Flask セキュリティの土台です。

生成は公式が示すワンライナーを使います。CPython の `secrets` モジュールで暗号学的に安全な乱数を作ります。

```bash
python -c 'import secrets; print(secrets.token_hex())'
```

設定はコードに直書きせず、必ず環境変数経由にします。`app.secret_key = ...` でも `app.config['SECRET_KEY'] = ...` でも構いません。本番では[Flask 本番運用ガイドの §4](/blog/flask-production-guide) で扱った `from_prefixed_env()` と組み合わせ、`FLASK_SECRET_KEY` から注入するのが筋です。

```python
import os
from flask import Flask

app = Flask(__name__)
# 環境変数から注入。コードにもリポジトリにも秘密を置かない
app.config['SECRET_KEY'] = os.environ['SECRET_KEY']
# あるいは from_prefixed_env() で FLASK_SECRET_KEY を読む（本番運用ガイド §4 参照）
```

> ⚠️ **絶対にやってはいけないこと**：`SECRET_KEY = 'dev'` や `SECRET_KEY = 'changeme'` のままデプロイする。これは「鍵がない」のと同じで、誰でもセッションを偽造できます。開発用の固定値とは別に、本番用の鍵を Secrets Manager / コンテナの環境変数から注入し、**コードにもログにも残さない**こと。`SECRET_KEY` をログに出力するのは情報漏洩です（[エラー処理・可観測性ガイド](/blog/flask-error-handling-logging-observability-guide) で「秘密・PII をログに残さない」原則を扱います）。

### 2.2 鍵ローテーション：`SECRET_KEY_FALLBACKS`（3.1 で追加）

`SECRET_KEY` が漏れた疑いがあるとき、あるいは定期的なセキュリティ運用として、鍵を交換したくなります。しかし鍵を素朴に差し替えると、**旧鍵で署名された既存セッション Cookie が全て無効になり、全ユーザーが即ログアウト**します。これは可用性の事故です。

Flask 3.1 で追加された `SECRET_KEY_FALLBACKS`（旧鍵のリスト）が、この問題を解きます。**新しい鍵で署名しつつ、旧鍵で署名された Cookie も検証を通す**——無停止で鍵をローテーションできます。

```python
app.config.update(
    SECRET_KEY=os.environ['SECRET_KEY'],              # 新しい鍵（これで署名する）
    SECRET_KEY_FALLBACKS=[os.environ['SECRET_KEY_OLD']],  # 旧鍵（検証だけ通す）
)
```

運用フローはこうなります。

1. 新しい鍵を生成し、`SECRET_KEY` に設定。旧鍵を `SECRET_KEY_FALLBACKS` に追加してデプロイ。
2. しばらく（既存セッションの寿命 = `PERMANENT_SESSION_LIFETIME` を超える期間）両方を有効にしておく。この間、既存ユーザーはアクセスのたびに新鍵で再署名される。
3. 旧鍵で署名された Cookie が事実上消えたら、`SECRET_KEY_FALLBACKS` から旧鍵を外して再デプロイ。

> 💡 **なぜ「リスト」なのか**：`SECRET_KEY_FALLBACKS` は配列なので、複数世代の旧鍵を同時に許容できます。鍵漏洩のインシデント対応では「いま使っている鍵を即座に交換し、旧鍵は短期間だけ受け入れる」という素早い切り替えが必要になります。3.1 以前はこれを手作業の `itsdangerous` 多重署名で実装する必要があり、技術的負債になりがちでした。3.1 では設定キー 1 つで済みます。

---

## **3. 安全な Cookie：本番の既定を「設定」で固める**

### 3.1 本番の Cookie ブロック

Flask のセッション Cookie には、セキュリティ属性を制御する設定キー群があります。**既定値は開発に最適化されており、本番では一部を必ず締め直す**必要があります。主要キーを一覧します。

| 設定キー | 既定値 | 本番推奨 | 役割 |
|---|---|---|---|
| `SESSION_COOKIE_NAME` | `'session'` | 任意 | Cookie 名 |
| `SESSION_COOKIE_HTTPONLY` | `True` | `True` | JS（`document.cookie`）から読めなくし、XSS による Cookie 窃取を防ぐ |
| `SESSION_COOKIE_SECURE` | **`False`** | **`True`** | HTTPS でのみ Cookie を送る。HTTP 平文での漏洩を防ぐ |
| `SESSION_COOKIE_SAMESITE` | `None` | `'Lax'` | クロスサイト送信を制限し、CSRF を緩和する |
| `SESSION_COOKIE_PARTITIONED` | `False`（3.1） | 必要時 `True` | CHIPS（パーティション化 Cookie）。有効化で `SECURE` も暗黙的に強制 |
| `SESSION_COOKIE_DOMAIN` | `None` | 通常 `None` | Cookie の有効ドメイン |
| `SESSION_COOKIE_PATH` | `None` | 通常 `None` | Cookie の有効パス |
| `SESSION_REFRESH_EACH_REQUEST` | `True` | `True` | 各リクエストで Cookie の有効期限を延長 |
| `MAX_COOKIE_SIZE` | `4093` | `4093` | 超えると警告（§1.3 参照） |

本番で最低限固めるブロックはこれです。`ProductionConfig` クラスや `create_app` 内で設定します。

```python
app.config.update(
    SESSION_COOKIE_SECURE=True,     # HTTPS 限定（本番は必須）
    SESSION_COOKIE_HTTPONLY=True,   # JS から読めない（既定だが意図を明示）
    SESSION_COOKIE_SAMESITE='Lax',  # クロスサイト送信を制限
)
```

各属性が守るものを整理します。

| 属性 | 値 | 防ぐ攻撃 |
|---|---|---|
| `Secure` | HTTPS のみ送信 | 中間者による平文 Cookie の盗聴 |
| `HttpOnly` | JS から不可視 | XSS で `document.cookie` を盗む攻撃 |
| `SameSite` | `Lax`（推奨）/ `Strict` | CSRF（クロスサイトからの自動送信） |

> ⚠️ **`SESSION_COOKIE_SECURE=True` は HTTPS が前提**。ローカルの平文 HTTP（`http://localhost`）では、`Secure` Cookie はブラウザに送られず**ログインできなくなります**。本番は TLS 終端の背後で動かすのが前提なので問題ありませんが、リバースプロキシ（nginx / ALB）の背後では `X-Forwarded-Proto` を正しく Flask に伝える `ProxyFix` の設定が要ります。この TLS とプロキシまわりは[本番デプロイガイド](/blog/flask-deployment-gunicorn-docker-production-wsgi-guide) で扱います。

> 💡 **`SameSite=Lax` か `Strict` か**：`Strict` は「外部サイトからのリンク遷移ですら Cookie を送らない」ため、外部リンクから来たユーザーがログイン切れに見える UX 問題が起きます。`Lax` は「トップレベルの GET ナビゲーション（普通のリンククリック）では送るが、クロスサイトの POST では送らない」という現実的なバランスで、**多くのアプリで `Lax` が最適解**です。決済確定のような特に重要な操作のみ `Strict` の別 Cookie に分離する、という設計もあります。

### 3.2 セッション固定攻撃とリプレイ攻撃の緩和

Cookie 属性を固めても、**セッションのライフサイクル**を誤ると穴が残ります。代表が 2 つです。

**セッション固定攻撃（Session Fixation）**：攻撃者が用意したセッション ID を被害者に使わせ、被害者がログインした後でその ID を乗っ取る攻撃です。対策は単純で、**ログイン成功時に古いセッションを必ずクリアし、新しいセッションを発行する**こと。公式の web セキュリティページのパターンがこれです。

```python
from flask import session

app.config.update(PERMANENT_SESSION_LIFETIME=600)  # セッション寿命を 600 秒に短縮


@app.route('/login', methods=['POST'])
def login():
    user = authenticate(request.form['username'], request.form['password'])
    if user is None:
        abort(401)
    session.clear()                 # 既存のセッションを破棄（固定攻撃の遮断）
    session['user_id'] = user.id    # 新しいセッションに最小限の識別子だけ入れる
    session.permanent = True        # PERMANENT_SESSION_LIFETIME を適用する
    return redirect('/dashboard')
```

**リプレイ攻撃の緩和**：`PERMANENT_SESSION_LIFETIME`（既定 `timedelta(days=31)`）は、`session.permanent = True` のセッションに有効期限を与えます。公式はこう述べています——「Flask の既定 Cookie 実装は、暗号署名がこの値より古くないことを検証する。この値を**小さくすると、リプレイ攻撃の緩和に役立つ**ことがある」。盗まれた古い Cookie の使い回しを、時間で無効化する仕組みです。機密性の高いアプリでは、寿命を 31 日ではなく数十分〜数時間に短縮します。

| 対策 | 手段 | 防ぐもの |
|---|---|---|
| セッション再生成 | ログイン時に `session.clear()` → 新規発行 | セッション固定攻撃 |
| セッション寿命 | `PERMANENT_SESSION_LIFETIME` を短縮 + `session.permanent` | リプレイ攻撃（古い Cookie の使い回し） |
| ログアウト | `session.clear()` | セッション残留 |

> 💡 **ログアウトは `session.clear()`**。`session.pop('user_id')` で個別キーを消すより、`session.clear()` で丸ごと破棄するほうが、消し忘れのキーが残る事故を防げます。SRP の観点でも「ログアウト = セッション全消去」という単純な不変条件にしておくほうが安全です。

---

## **4. CSRF：核に無い理由と、Flask-WTF での実装**

### 4.1 なぜ Flask 核に CSRF が無いのか

CSRF（クロスサイトリクエストフォージェリ）は、ログイン済みユーザーのブラウザに、攻撃者のサイトから本物のサイトへ意図しないリクエスト（例：「送金する」POST）を送らせる攻撃です。Cookie はブラウザが自動で付けるため、ユーザーの認証情報がそのまま悪用されます。

Flask は CSRF 対策を**核に持ちません**。公式セキュリティページは、その設計判断を明示しています——「なぜ Flask がそれをやってくれないのか？ それが起きるべき理想的な場所は**フォーム検証フレームワーク**であり、それは Flask には存在しないからだ」。Flask が「micro（核だけ）」である哲学の、首尾一貫した帰結です。CSRF 対策はフォーム検証層（= 拡張）の責務として、**Flask-WTF** に委ねます。

### 4.2 `CSRFProtect` のセットアップ（ファクトリ形）

Flask-WTF の `CSRFProtect` は、**全ての状態変更リクエスト（POST / PUT / PATCH / DELETE）にトークン検証を要求**します。アプリケーションファクトリと整合させるため、他の拡張と同じく「未束縛で生成 → `init_app` で束縛」のパターンに乗せます（この設計の背景は[大規模構成ガイド](/blog/flask-application-factory-blueprints-large-app-structure-guide)を参照）。

```python
# extensions.py — どのアプリにも束縛されていない「裸」の拡張
from flask_wtf.csrf import CSRFProtect

csrf = CSRFProtect()
```

```python
# __init__.py — アプリケーションファクトリ内で束縛
from flask import Flask

from .extensions import csrf


def create_app():
    app = Flask(__name__)
    app.config['SECRET_KEY'] = os.environ['SECRET_KEY']  # CSRF トークン署名にも使われる
    csrf.init_app(app)
    # ...
    return app
```

> 💡 **`CSRFProtect` も `SECRET_KEY` に依存する**。CSRF トークンは `SECRET_KEY`（または `WTF_CSRF_SECRET_KEY` を別に設定すればそちら）で署名されます。つまり §2 の `SECRET_KEY` 管理は、セッションだけでなく CSRF 防御の土台でもあります。鍵が一つで二役を担う以上、その生成・保管・ローテーションの厳格さは二重に重要です。

### 4.3 テンプレートへのトークン埋め込み

`CSRFProtect` を有効にすると、フォームには CSRF トークンの hidden フィールドが必須になります。`FlaskForm`（WTForms）を使う場合は `{{ form.csrf_token }}` が出力します。素の HTML フォームには `csrf_token()` ヘルパーで埋め込みます。

```html
<!-- FlaskForm を使う場合 -->
<form method="post">
  {{ form.csrf_token }}
  <input type="text" name="title">
  <button type="submit">保存</button>
</form>

<!-- 素の HTML フォームの場合 -->
<form method="post">
  <input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
  <input type="text" name="title">
  <button type="submit">保存</button>
</form>
```

トークンのフィールド名は `WTF_CSRF_FIELD_NAME`（既定 `'csrf_token'`）、有効期限は `WTF_CSRF_TIME_LIMIT`（既定 `3600` 秒）で制御できます。

### 4.4 AJAX：`X-CSRFToken` ヘッダで送る

JavaScript からの `fetch` / `XMLHttpRequest` では hidden フィールドが使えないため、トークンを**HTTP ヘッダ `X-CSRFToken`** で送ります。トークンは `<meta>` タグに埋めておき、JS から読み出すのが定石です。

```html
<!-- レイアウトの <head> にトークンを埋める -->
<meta name="csrf-token" content="{{ csrf_token() }}">
```

```javascript
// meta タグから読んで、状態変更リクエストのヘッダに付ける
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;

fetch('/api/authors', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRFToken': csrfToken,   // Flask-WTF がこのヘッダを検証する
  },
  body: JSON.stringify({ name: '友田' }),
});
```

### 4.5 API エンドポイントの除外：CSRF が「効かない」境界を理解する

ここが設計判断として最も重要です。**ステートレスな Bearer トークン認証の API には、CSRF 保護は原理的に不要**です。理由を正確に押さえます。

CSRF が成立するのは「**ブラウザが認証情報（Cookie）を自動で付ける**」からです。攻撃者のサイトから POST させても、ブラウザが被害者の Cookie を勝手に同梱するため攻撃が通ります。一方、`Authorization: Bearer <token>` のようなヘッダ認証は、**ブラウザが自動では付けません**。攻撃者のサイトの JS は、被害者のトークンを読み出して他オリジンの API に付与することが（同一オリジンポリシー / CORS により）できません。つまり「ブラウザの自動送信」という CSRF の前提が崩れるため、**トークン認証 API に CSRF トークンを足すのは無意味な二重防御**になります。

したがって、Cookie セッションで守るブラウザ向けフォームには `CSRFProtect` を効かせ、トークン認証の API エンドポイントは `@csrf.exempt` で除外します。

```python
from flask import Blueprint, jsonify

# トークン認証の API Blueprint は CSRF 検証から除外する
api_bp = Blueprint('api', __name__, url_prefix='/api')


@api_bp.before_request
def require_bearer_token():
    # ここで Authorization: Bearer を検証する（Cookie に依存しない認証）
    ...


# Blueprint 全体を除外
csrf.exempt(api_bp)


# 個別のビューを除外する場合
@app.route('/webhook', methods=['POST'])
@csrf.exempt
def stripe_webhook():
    # 外部サービスからの Webhook は署名検証で守る（CSRF トークンは持てない）
    ...
```

> ⚠️ **「除外」は無防備ではない**。`@csrf.exempt` は「CSRF トークン検証をしない」だけで、**その分の認証・認可を別の手段で必ず張る**のが前提です。トークン認証 API なら `Authorization` ヘッダの検証、外部 Webhook なら送信元の署名検証（Stripe なら署名ヘッダの HMAC 検証）。除外したエンドポイントを認証なしで晒すのは本末転倒です。「CSRF が不要な理由（ブラウザの自動送信が無い）」と「認証が不要な理由」は別物だと、常に切り分けてください。

| 認証方式 | CSRF 保護 | 理由 |
|---|---|---|
| Cookie セッション（ブラウザフォーム） | **必要** | ブラウザが Cookie を自動送信するため攻撃が成立する |
| `Authorization: Bearer` トークン | **不要**（`@csrf.exempt`） | ブラウザが自動送信せず、CSRF の前提が崩れる |
| 外部 Webhook | 除外 + 署名検証 | トークンを持てない。送信元署名（HMAC）で真正性を担保 |

---

## **5. XSS と自動エスケープ：Jinja が守る範囲と、守らない範囲**

### 5.1 Jinja の自動エスケープ規則

XSS（クロスサイトスクリプティング）は、ユーザー入力に混ざった `<script>` などがそのまま HTML として実行される攻撃です。Flask のテンプレートエンジン **Jinja は、既定で自動エスケープが有効**で、これが最初の防壁になります。`{{ name }}` に `<script>` が来ても、`&lt;script&gt;` に変換されて無害化されます。

ただし**自動エスケープが効くのは拡張子が `.html` / `.htm` / `.xml` / `.xhtml` のテンプレートだけ**である点が、見落とされがちな落とし穴です。

| テンプレートの種類 | 自動エスケープ |
|---|---|
| `.html` / `.htm` / `.xml` / `.xhtml` ファイル | ✅ 有効（既定） |
| 上記以外の拡張子（`.txt` など） | ❌ 無効 |
| **文字列から直接ロードしたテンプレート**（`render_template_string` 等） | ❌ 無効 |

> ⚠️ **文字列テンプレートは自動エスケープされない**。`render_template_string(user_supplied)` のように**ユーザー入力をテンプレート文字列として渡す**のは、自動エスケープが無効なうえに **SSTI（サーバーサイドテンプレートインジェクション）** の温床で、二重に危険です。テンプレートは必ず `.html` ファイルとして持ち、ユーザー入力は**データ（`{{ var }}` に渡す値）**として扱い、テンプレートそのものにはしないこと。

### 5.2 `markupsafe` の `escape` と `Markup`

テンプレートの外（ビュー関数が直接 HTML 断片を返す等）で手動エスケープが必要なときは、`escape` を使います。**重要な変更点として、`escape` と `Markup` は現在 `flask` ではなく `markupsafe` から import します**（以前は `flask` からも取れましたが、現在は `markupsafe` が正です）。

```python
from markupsafe import escape


@app.route("/hello/<name>")
def hello(name):
    # name はユーザー入力。escape で <, >, & 等を無害化してから埋め込む
    return f"Hello, {escape(name)}!"
```

逆に「この文字列は安全な HTML だと自分が保証する」と宣言するのが `Markup` です。これは自動エスケープを**意図的に無効化**する、強力で危険な道具です。

```python
from markupsafe import Markup

# Markup でラップした文字列はエスケープされずにそのまま HTML として出力される
safe = Markup("<strong>太字</strong>")
```

> ⚠️ **`Markup()` をユーザー入力に使わない**。公式が明示的に警告しています。`Markup(user_input)` は「ユーザー入力を信頼できる HTML として扱う」という意味になり、**自動エスケープを自ら無効化して XSS の穴を開ける**行為です。`Markup` を使ってよいのは、自分が完全に制御している定数 HTML だけ。ユーザー由来の文字列を `Markup` に通すコードを見たら、それは脆弱性だと考えてください。

### 5.3 自動エスケープでも防げない XSS パターン

「Jinja の自動エスケープがあるから XSS は安心」も誤りです。公式ドキュメントは、自動エスケープが守らない代表的なパターンを挙げています。

- **引用符で囲まないHTML属性**：`<a href={{ value }}>` のように属性値を引用符で囲まないと、`value` に空白や `onmouseover=...` を混ぜられてエスケープをすり抜けられます。**属性値は必ず引用符で囲む**：`<a href="{{ value }}">`。
- **`javascript:` スキームの URL**：`<a href="{{ user_url }}">` の `user_url` が `javascript:alert(1)` だと、クリックでスクリプトが走ります。属性のエスケープでは防げません。**URL のスキーム（`http`/`https` のみ許可）を検証**するか、後述の CSP で `javascript:` を禁じます。
- **`Markup()` をユーザーデータに適用**（§5.2 のとおり）。

```html
<!-- ❌ 危険：属性値を引用符で囲んでいない -->
<a href={{ url }}>link</a>

<!-- ✅ 安全：属性値を引用符で囲む -->
<a href="{{ url }}">link</a>
```

これらは「テンプレートの書き方」の問題であり、エスケープ機能ではカバーしきれません。だからこそ、次節の **CSP を多層防御（defense-in-depth）** として重ねます。

---

## **6. セキュリティヘッダ：既定では付かないものを付ける**

### 6.1 付けるべきヘッダ

Flask は**セキュリティ系のレスポンスヘッダを既定では一切付けません**。HSTS・CSP・`nosniff`・フレーム制御は、自分で付与するか拡張に任せます。本番で付けるべき基本セットはこれです。

| ヘッダ | 推奨値 | 役割 |
|---|---|---|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | 以後 HTTPS のみで接続させる（HSTS）。ダウングレード攻撃を防ぐ |
| `Content-Security-Policy` | `default-src 'self'` | スクリプト等の取得元を制限し、XSS を多層防御する |
| `X-Content-Type-Options` | `nosniff` | MIME スニッフィングを禁止し、誤った型解釈による実行を防ぐ |
| `X-Frame-Options` | `SAMEORIGIN` | 他サイトからの iframe 埋め込みを禁止し、クリックジャッキングを防ぐ |

> 💡 **`X-XSS-Protection` は付けない**。かつて推奨された `X-XSS-Protection` ヘッダは現在**非推奨**で、ブラウザ側のフィルタ自体が廃止されています。公式の推奨リストにも載っていません。古いブログや生成 AI の出力がこれを勧めることがありますが、付けても効果はなく、設定によってはかえって脆弱性を生むため**入れないのが正解**です。XSS の防御は「Jinja 自動エスケープ + 正しいテンプレート記述 + CSP」で構成します。

### 6.2 `after_request` で付与する（最小実装）

外部拡張を増やさず、`after_request` フックですべてのレスポンスにヘッダを付ける実装です。グローバルな副作用を一箇所に集約でき、何が付くかがコードから一目で分かります。

```python
from flask import Flask

app = Flask(__name__)


@app.after_request
def set_security_headers(response):
    """全レスポンスにセキュリティヘッダを付与する（SRP：ヘッダ付与だけを担う）。"""
    response.headers['Strict-Transport-Security'] = (
        'max-age=31536000; includeSubDomains'
    )
    response.headers['Content-Security-Policy'] = "default-src 'self'"
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'SAMEORIGIN'
    return response
```

> ⚠️ **HSTS は HTTPS でのみ意味を持つ**。`Strict-Transport-Security` は HTTPS で配信して初めて有効です。開発の平文 HTTP で付けると、ブラウザがそのホストを「HTTPS 必須」と記憶してしまい、ローカルで困ることがあります。本番（TLS 終端の背後）でのみ付ける、あるいは `app.debug` を見て出し分けるのが安全です。CSP も `default-src 'self'` は厳しめの出発点なので、外部 CDN・解析タグ・インラインスクリプトを使うサイトでは**自分のアプリに合わせて段階的に緩める**チューニングが要ります（まず `Content-Security-Policy-Report-Only` で観測してから本適用するのが定石）。

### 6.3 `after_request` vs Flask-Talisman

ヘッダ付与には専用拡張 **Flask-Talisman** という選択肢もあります。HTTPS 強制リダイレクト・HSTS・CSP（nonce 生成含む）・フレーム制御などを一括で面倒見てくれます。

| 観点 | `after_request`（手動） | Flask-Talisman |
|---|---|---|
| 依存追加 | なし（標準機能のみ） | 拡張 1 つ追加 |
| 透明性 | 何が付くかコードで一目瞭然 | 既定値が暗黙的（要ドキュメント確認） |
| CSP nonce | 自前実装が必要 | 組み込みでサポート |
| HTTPS 強制リダイレクト | 自前 | 組み込み |
| 向いているケース | ヘッダが数個・要件が単純 | CSP nonce や HTTPS 強制が要る複雑な要件 |

筆者の B2B SaaS では、CSP が単純（自オリジン中心）で要件が固定だったため、依存を増やさず `after_request` の数行で管理しました。**「拡張で楽になる」よりも「何が付いているか明示的に分かる」ことを優先**した判断です。CSP に nonce が要る、HTTPS 強制リダイレクトを Flask 層でやりたい、といった要件が出てきたら Talisman に移行します。これは「現在の要件に最小フィットさせ、必要になってから足す」という YAGNI の実践です。

---

## **7. DoS とリソース制限：入力の「大きさ」も境界で止める**

セキュリティは「不正な値」だけでなく「**過大なリクエスト**」からの防御も含みます。巨大なボディや膨大なフォームパートを無制限に受け付けると、メモリ枯渇によるサービス拒否（DoS）になります。Flask 3.1 系には、これを設定で止める仕組みがあります。

| 設定キー | 既定値 | 追加 | 役割 |
|---|---|---|---|
| `MAX_CONTENT_LENGTH` | `None` | — | リクエストボディの最大バイト数。超過で 413 を返す |
| `MAX_FORM_MEMORY_SIZE` | `500_000`（500kB） | 3.1 | 非ファイルのフォーム値の合計サイズ上限 |
| `MAX_FORM_PARTS` | `1_000` | 3.1 | マルチパートフォームのパート数上限 |
| `TRUSTED_HOSTS` | `None` | 3.1 | ルーティング時に Host ヘッダを検証 |

```python
app.config.update(
    MAX_CONTENT_LENGTH=10 * 1024 * 1024,   # ボディ全体を 10MB に制限
    # MAX_FORM_MEMORY_SIZE / MAX_FORM_PARTS は 3.1 の既定値で十分なことが多い
    TRUSTED_HOSTS=['example.com', 'www.example.com'],  # Host ヘッダ攻撃対策
)
```

押さえるべき点を 3 つ。

- **`MAX_CONTENT_LENGTH` の既定は `None`（無制限）**。ファイルアップロードを受けるなら**必ず明示設定**します。Flask 3.1 では `Request.max_content_length` でエンドポイント単位の上限も設定でき、「アップロードのルートだけ大きく、他は小さく」という細かい制御ができます。
- **`MAX_FORM_MEMORY_SIZE` / `MAX_FORM_PARTS` は 3.1 で既定が付いた**。公式によれば、3.1 の既定では**1 つのフォームが占有するメモリは最大でも約 500MB に収まる**よう設計されています。古いバージョンからの移行では、この既定が増えたことで挙動が変わる可能性があるので、巨大フォームを扱うアプリは値を見直します。
- **`TRUSTED_HOSTS`（3.1）で Host ヘッダ攻撃を防ぐ**。`Host` ヘッダを信頼してパスワードリセットリンクを生成するようなコードは、偽の `Host` を送られると「攻撃者ドメインへのリセットリンク」を発行してしまいます。`TRUSTED_HOSTS` はルーティング時に `Host` を検証し、これを遮断します。

> 💡 **アプリ層の限界と WAF**：ここまでの DoS 対策は「1 リクエストの大きさ」を制限するもので、**「大量のリクエストが来る」DDoS は別レイヤ**の問題です。レート制限・IP 評価・大規模なボリューム攻撃の吸収は、アプリの前段（WAF / CDN / ロードバランサ）で行うのが筋です。アプリ層の入力検証と前段の WAF を組み合わせた多層防御の設計は、[WAF による多層防御ガイド](/blog/waf-defense-in-depth-aws-waf-cloud-armor-owasp-guide)にまとめています。Flask の `MAX_*` 設定は「前段をすり抜けた、あるいは認証済みユーザーからの」過大入力を止める最後の砦として位置づけます。

---

## **8. まとめと Flask セキュリティ・チェックリスト**

Flask のセキュリティは、フレームワークの「賢い既定」と「あなたが明示的に固める境界」の正確な切り分けで決まります。本記事の要点を再掲します。

1. **`session` は署名付きクライアント Cookie**。完全性（改ざん検知）は守るが**機密性は守らない**——中身は読める。秘密や大きなデータはサーバー側セッションへ。
2. **`SECRET_KEY` がすべての署名の根**。`secrets.token_hex()` で生成し環境変数で注入。3.1 の `SECRET_KEY_FALLBACKS` で無停止の鍵ローテーション。
3. **本番 Cookie は `SECURE` / `HttpOnly` / `SameSite='Lax'`** で固め、ログイン時の `session.clear()` + 再生成で固定攻撃を、`PERMANENT_SESSION_LIFETIME` の短縮でリプレイ攻撃を緩和する。
4. **CSRF は核に無い**（公式の設計判断）。Flask-WTF の `CSRFProtect` をファクトリで `init_app`、テンプレートトークンと AJAX の `X-CSRFToken` で守る。**ステートレスなトークン認証 API は `@csrf.exempt`**（CSRF の前提が成立しないため）。
5. **XSS は Jinja 自動エスケープ**（`.html` 等）が既定の防壁。`escape` / `Markup` は **`markupsafe`** 由来。引用符なし属性・`javascript:` URL・`Markup(ユーザー入力)` は防げないので、CSP で多層防御。
6. **セキュリティヘッダは既定で付かない**。HSTS / CSP / `nosniff` / `X-Frame-Options` を `after_request` か Flask-Talisman で付与（`X-XSS-Protection` は非推奨なので付けない）。
7. **DoS は入力の「大きさ」も止める**。`MAX_CONTENT_LENGTH` / `MAX_FORM_MEMORY_SIZE` / `MAX_FORM_PARTS` / `TRUSTED_HOSTS`（3.1）。大量リクエストは前段の WAF で。

最後に、本番投入前に通すべきチェックリストです。筆者がマルチテナント B2B SaaS で実際に確認している項目を整理しました。

| 区分 | チェック項目 | 確認 |
|---|---|---|
| 鍵 | `SECRET_KEY` を `secrets.token_hex()` で生成し、環境変数から注入している | ☐ |
| 鍵 | `SECRET_KEY` がコード・リポジトリ・ログのどこにも残っていない | ☐ |
| 鍵 | 鍵ローテーション手順（`SECRET_KEY_FALLBACKS`）を運用に用意している | ☐ |
| Cookie | 本番で `SESSION_COOKIE_SECURE=True` | ☐ |
| Cookie | `SESSION_COOKIE_HTTPONLY=True` / `SESSION_COOKIE_SAMESITE='Lax'` | ☐ |
| セッション | ログイン時に `session.clear()` + 再生成、ログアウトで `session.clear()` | ☐ |
| セッション | `session` に秘密・大きなデータを入れていない（ID のみ） | ☐ |
| セッション | `PERMANENT_SESSION_LIFETIME` を要件に合わせて短縮している | ☐ |
| CSRF | ブラウザフォームに `CSRFProtect` が効いている | ☐ |
| CSRF | トークン認証 API / Webhook は `@csrf.exempt` し、別手段（Bearer / 署名）で認証している | ☐ |
| XSS | テンプレートは `.html` ファイル、`render_template_string(ユーザー入力)` を使っていない | ☐ |
| XSS | 属性値を引用符で囲み、URL のスキームを検証、`Markup(ユーザー入力)` が無い | ☐ |
| ヘッダ | HSTS / CSP / `nosniff` / `X-Frame-Options` を付与（`X-XSS-Protection` は付けない） | ☐ |
| DoS | `MAX_CONTENT_LENGTH` を明示設定、`TRUSTED_HOSTS`（3.1）で Host を検証 | ☐ |
| 認可 | 認証（誰か）と認可（何をしてよいか）を分離し、認可を DB と突き合わせている | ☐ |

セッション・Cookie・CSRF・XSS・ヘッダ——これらは「アプリに入れる外部入力」と「アプリが返す出力」の境界を固める仕事です。境界をどれだけ宣言的に・設定で守れているかが、`session['role']='admin'` のような改ざんや、`{"role":"admin"}` のようなマスアサインメント（[marshmallow による REST API 境界設計](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) で `load_only` / `dump_only` を使って構造的に防ぐ手法を解説）を未然に止めます。

Flask のセキュリティ全体像と他の設計対象（構成・コンテキスト・デプロイ・テスト・可観測性）との接続は、[Flask 本番運用ガイド](/blog/flask-production-guide)に戻って俯瞰してください。そして「認証されたユーザーが、許されたデータにだけアクセスできる」という認可の設計は、セッションの一段上の話として[マルチテナント SaaS のデータ分離・認可設計ガイド](/blog/multi-tenant-saas-data-isolation-authorization-design-guide)に分けています。境界を一つずつ、設定とコードの構造で固めていくこと——それが、事業の信頼性を支える Flask セキュリティの実装です。
