# Flask Security Implementation Guide (3.1 Series): Signed-Cookie Sessions, SECRET_KEY, Secure Cookies, CSRF, XSS Auto-Escaping, and Security Headers

> An implementation guide for hardening Flask 3.1-series security boundaries at production quality. Explained with real code faithful to the official documentation: the true nature of the client-side signed-cookie session, SECRET_KEY and key rotation, SECURE/HttpOnly/SameSite secure cookies, Flask-WTF's CSRFProtect, Jinja's auto-escaping, HSTS/CSP/nosniff security headers, and DoS countermeasures.

- Published: 2026-06-21
- Author: 友田 陽大
- Tags: Python, Flask, セキュリティ, CSRF, Cookie, XSS, バックエンド
- URL: https://tomodahinata.com/en/blog/flask-security-sessions-csrf-secure-cookies-guide
- Category: Flask in production
- Pillar guide: https://tomodahinata.com/en/blog/flask-production-guide

## Key points

- Flask's session is a client-side signed cookie. Because it's signed with ItsDangerous, tampering can be detected but the contents can be read — it protects 'integrity' but not 'confidentiality.' Put secrets and large data in a server-side session
- SECRET_KEY is the linchpin of session signing. Generate it with secrets.token_hex() and inject via an environment variable. With 3.1's added SECRET_KEY_FALLBACKS, you can rotate keys with zero downtime while still verifying old-key signatures
- Harden production cookies with SECURE/HttpOnly/SameSite='Lax'. Mitigate session fixation with session.clear() + regeneration at login, and replay attacks with PERMANENT_SESSION_LIFETIME
- CSRF is not in Flask's core (an official design decision). init_app Flask-WTF's CSRFProtect in the factory, and protect with a template token and AJAX's X-CSRFToken header. A stateless Bearer-token API needs no CSRF in principle
- For XSS, Jinja's auto-escaping (.html, etc.) is the default bulwark. escape/Markup come from markupsafe. Security headers (HSTS/CSP/nosniff/frame-options) aren't added by default, so add them with after_request or Flask-Talisman

---

## **Introduction: Flask security is "smart defaults + your choices"**

When talking about Flask security, there are 2 misconceptions you must first correct.

1. **"The values I put in `session` are secret"** — wrong. Flask's `session` is a **client-side signed cookie**. The signature can detect tampering, but **the user can read the contents**.
2. **"The framework protects everything against CSRF and XSS"** — only half right. XSS is protected by Jinja's auto-escaping by default, but **CSRF doesn't exist in Flask's core**. Security headers aren't added at all by default either.

This article is a deep dive (a spoke) for implementing §7 (Security) of the [Flask production guide](/blog/flask-production-guide) at production quality. What it covers is the accurate separation of "what Flask protects by default" and "the boundaries you must explicitly harden," and their implementation.

The author has **designed and implemented the backend of a B2B SaaS that won the Minister of Economy, Trade and Industry Award in Python / Flask / SQLAlchemy / PostgreSQL, and operated it in production in a multi-tenant configuration**. Under the tension that the business is over if data mixes between tenants, I hardened the boundary design written here one by one. The code in this article is based on that real combat and the Flask 3.1-series official documentation.

> 💡 **The version covered in this article**: it assumes the **Flask 3.1 series**. Flask 3.1 added settings that directly bear on production hardening — `SECRET_KEY_FALLBACKS` (key rotation), `SESSION_COOKIE_PARTITIONED` (CHIPS), `MAX_FORM_MEMORY_SIZE` / `MAX_FORM_PARTS` (DoS mitigation), and `TRUSTED_HOSTS` (Host header verification). This article covers them in order.

---

## **1. Flask's session: it is a "signed client cookie"**

### 1.1 What it protects and what it doesn't

First, let me accurately pin down the true nature of Flask's `session` in the official words. The Quickstart states — "the session is implemented on top of cookies and **signs** the cookies cryptographically. The user **can look at** the contents of the cookie but **cannot modify** it unless they know the secret key used for signing." The signing implementation is the dependency library **ItsDangerous**.

The conclusion drawn from here is extremely important for security design.

| Property | Flask's `session` cookie | Meaning |
|---|---|---|
| **Integrity** | ✅ Protected | Tampering can be detected by the signature. A rewrite to `session['role']='admin'` is rejected |
| **Confidentiality** | ❌ Not protected | Anyone can Base64-decode and **read** the contents (it's not encryption) |
| **Authenticity** | ✅ Protected | Guarantees the server holding the secret key did the signing |

Usage itself is straightforward. `session` behaves like a dict.

```python
from flask import session

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

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

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

### 1.2 The iron rules of design brought by "readable"

From the fact that `session`'s contents are readable, 2 operational iron rules emerge.

- **Don't put secrets in `session`.** You must not store the "raw values" of API keys, passwords, personal information that would be problematic if seen by others, or internal flags in `session`. What you may put in is only **an identifier whose harm is small as long as there's a signature even if leaked**, like a "user ID." You pull the actual data from the DB by ID.
- **Don't depend authorization solely on `session`'s values.** `session['user_id']` can't be tampered with, but whether that ID "may access this resource in this tenant right now" is a separate matter. Determine authorization by matching against the DB's state (split into the [multi-tenant authorization design](/blog/multi-tenant-saas-data-isolation-authorization-design-guide) described later).

> ⚠️ **The most frequent misconception**: "`session` is encrypted, so it's OK to put secrets in it." This is wrong. Signing and encryption are different things, and Flask's default is **signing only**. Take a `session` cookie out with the browser's developer tools and Base64-decode it, and you can read the JSON. If you want to put a secret on a cookie, use a mechanism that includes encryption, or move it to a server-side session in the first place.

### 1.3 When to move to a server-side session

A client-side cookie session has one more practical constraint. **The cookie's size limit.** Flask warns when you exceed `MAX_COOKIE_SIZE` (default 4093 bytes). It's a constraint stemming from the browser's cookie size limit (roughly 4KB), and exceeding it can make the session **silently break** (the cookie isn't sent).

If any of the following applies, consider a **server-side session** (an extension like `Flask-Session`, etc., which stores the session body in Redis / DB / files and puts only the ID on the cookie) instead of a client cookie.

- The data you want to put in the session exceeds a few KB (cart, mid-wizard state, etc.).
- You need to retain sensitive data in the session and don't want to hand it to the client.
- "You want to immediately invalidate a specific user's session from the server side" (a client cookie is hard to invalidate from the server).

> 💡 **Applications of "signed cookies"**: when you want to use signed cookies or signed tokens (such as password-reset links) for data other than the session, you can directly use the same **`itsdangerous`** Flask uses internally. With `itsdangerous.URLSafeTimedSerializer` you can issue and verify "signed tokens with an expiry." Reusing the same foundation rather than reinventing `session` is a well-built design.

---

## **2. SECRET_KEY: the root of all signing**

### 2.1 Why it's required, and how to generate it

The `session` signing is done with `SECRET_KEY`. **If this is unset, the session can't be used**, and **if it's weak / leaked, the signature can be forged, and tampering like `session['role']='admin'` gets through**. `SECRET_KEY` is the foundation of Flask security.

For generation, use the one-liner the official docs show. It makes a cryptographically secure random number with CPython's `secrets` module.

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

For configuration, don't write it directly in the code; always go via an environment variable. Either `app.secret_key = ...` or `app.config['SECRET_KEY'] = ...` is fine. In production, the proper way is to combine it with `from_prefixed_env()` covered in [§4 of the Flask production guide](/blog/flask-production-guide) and inject it from `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 参照）
```

> ⚠️ **What you absolutely must not do**: deploying with `SECRET_KEY = 'dev'` or `SECRET_KEY = 'changeme'` left as-is. This is the same as "having no key," and anyone can forge sessions. Separate from a fixed development value, inject the production key from a Secrets Manager / the container's environment variable, and **leave it neither in the code nor in the logs**. Outputting `SECRET_KEY` to logs is an information leak (the [error-handling / observability guide](/blog/flask-error-handling-logging-observability-guide) covers the "don't leave secrets / PII in logs" principle).

### 2.2 Key rotation: `SECRET_KEY_FALLBACKS` (added in 3.1)

When there's suspicion that `SECRET_KEY` leaked, or as routine security operation, you'll want to swap the key. But naively replacing the key **invalidates all existing session cookies signed with the old key, immediately logging out all users**. This is an availability accident.

`SECRET_KEY_FALLBACKS` (a list of old keys), added in Flask 3.1, solves this problem. **Sign with the new key while also passing verification for cookies signed with old keys** — you can rotate keys with zero downtime.

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

The operational flow becomes this.

1. Generate a new key and set it to `SECRET_KEY`. Add the old key to `SECRET_KEY_FALLBACKS` and deploy.
2. Keep both valid for a while (a period exceeding the lifetime of existing sessions = `PERMANENT_SESSION_LIFETIME`). During this, existing users are re-signed with the new key on each access.
3. Once cookies signed with the old key have effectively disappeared, remove the old key from `SECRET_KEY_FALLBACKS` and redeploy.

> 💡 **Why a "list"**: `SECRET_KEY_FALLBACKS` is an array, so it can tolerate multiple generations of old keys at once. In incident response for a key leak, you need a quick switch of "immediately swap the key in use now, and accept the old key only for a short period." Before 3.1, you had to implement this with manual `itsdangerous` multi-signing, which tended to become technical debt. In 3.1, one config key suffices.

---

## **3. Secure cookies: hardening the production defaults with "settings"**

### 3.1 The production cookie block

Flask's session cookie has a group of config keys that control security attributes. **The defaults are optimized for development, and in production you must tighten some of them back up.** Let me list the main keys.

| Config key | Default | Production recommendation | Role |
|---|---|---|---|
| `SESSION_COOKIE_NAME` | `'session'` | Any | Cookie name |
| `SESSION_COOKIE_HTTPONLY` | `True` | `True` | Make it unreadable from JS (`document.cookie`), preventing cookie theft via XSS |
| `SESSION_COOKIE_SECURE` | **`False`** | **`True`** | Send the cookie only over HTTPS. Prevent leakage over plaintext HTTP |
| `SESSION_COOKIE_SAMESITE` | `None` | `'Lax'` | Restrict cross-site sending, mitigating CSRF |
| `SESSION_COOKIE_PARTITIONED` | `False` (3.1) | `True` when needed | CHIPS (partitioned cookies). Enabling it also implicitly forces `SECURE` |
| `SESSION_COOKIE_DOMAIN` | `None` | Usually `None` | The cookie's valid domain |
| `SESSION_COOKIE_PATH` | `None` | Usually `None` | The cookie's valid path |
| `SESSION_REFRESH_EACH_REQUEST` | `True` | `True` | Extend the cookie's expiry on each request |
| `MAX_COOKIE_SIZE` | `4093` | `4093` | Warns when exceeded (see §1.3) |

The block to harden at minimum in production is this. Set it in a `ProductionConfig` class or inside `create_app`.

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

Let me organize what each attribute protects.

| Attribute | Value | Attack it prevents |
|---|---|---|
| `Secure` | Sent only over HTTPS | Eavesdropping of a plaintext cookie by a man-in-the-middle |
| `HttpOnly` | Invisible from JS | An attack stealing `document.cookie` via XSS |
| `SameSite` | `Lax` (recommended) / `Strict` | CSRF (automatic sending from a cross-site) |

> ⚠️ **`SESSION_COOKIE_SECURE=True` presupposes HTTPS.** Over local plaintext HTTP (`http://localhost`), a `Secure` cookie isn't sent to the browser, and you **can't log in**. Production runs behind TLS termination so it's no problem, but behind a reverse proxy (nginx / ALB) you need a `ProxyFix` setting that correctly conveys `X-Forwarded-Proto` to Flask. This TLS and proxy area is covered in the [production deployment guide](/blog/flask-deployment-gunicorn-docker-production-wsgi-guide).

> 💡 **`SameSite=Lax` or `Strict`**: `Strict` "doesn't send the cookie even on a link transition from an external site," so it causes a UX problem where a user coming from an external link appears logged out. `Lax` is a realistic balance of "send on a top-level GET navigation (an ordinary link click), but not on a cross-site POST," and **`Lax` is the optimal answer for many apps**. There's also a design of separating only especially important operations like payment confirmation into a separate `Strict` cookie.

### 3.2 Mitigating session fixation and replay attacks

Even after hardening the cookie attributes, getting the **session lifecycle** wrong leaves holes. There are 2 representative ones.

**Session Fixation**: an attack where the attacker has the victim use a session ID the attacker prepared, then hijacks that ID after the victim logs in. The countermeasure is simple — **always clear the old session at login success and issue a new session**. This is the pattern in the official web-security page.

```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')
```

**Mitigating replay attacks**: `PERMANENT_SESSION_LIFETIME` (default `timedelta(days=31)`) gives an expiry to sessions where `session.permanent = True`. The official docs state — "Flask's default cookie implementation validates that the cryptographic signature is not older than this value. Making this value **smaller can help mitigate replay attacks**." It's a mechanism to invalidate, by time, the reuse of a stolen old cookie. In high-confidentiality apps, shorten the lifetime to tens of minutes to a few hours rather than 31 days.

| Countermeasure | Means | What it prevents |
|---|---|---|
| Session regeneration | `session.clear()` → new issuance at login | Session fixation |
| Session lifetime | Shorten `PERMANENT_SESSION_LIFETIME` + `session.permanent` | Replay attack (reuse of an old cookie) |
| Logout | `session.clear()` | Session residue |

> 💡 **Logout is `session.clear()`.** Rather than deleting individual keys with `session.pop('user_id')`, discarding the whole thing with `session.clear()` prevents the accident of a forgotten key remaining. From the SRP standpoint too, keeping it the simple invariant "logout = full session erasure" is safer.

---

## **4. CSRF: the reason it's not in the core, and implementation with Flask-WTF**

### 4.1 Why CSRF isn't in Flask's core

CSRF (cross-site request forgery) is an attack that makes a logged-in user's browser send an unintended request (e.g. a "send money" POST) from the attacker's site to the real site. Because the browser automatically attaches the cookie, the user's authentication info is exploited as-is.

Flask **doesn't have a CSRF countermeasure in its core**. The official security page makes its design decision explicit — "Why does Flask not do that for you? The ideal place for this to happen is the **form validation framework**, which does not exist in Flask." It's a consistent consequence of Flask's philosophy of being "micro (core only)." The CSRF countermeasure is entrusted to **Flask-WTF** as the responsibility of the form-validation layer (= an extension).

### 4.2 Setting up `CSRFProtect` (factory form)

Flask-WTF's `CSRFProtect` **requires token verification on all state-changing requests (POST / PUT / PATCH / DELETE)**. To be consistent with the application factory, put it on the same "create unbound → bind with `init_app`" pattern as other extensions (for the background of this design, see the [large-structure guide](/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` also depends on `SECRET_KEY`.** The CSRF token is signed with `SECRET_KEY` (or, if you set `WTF_CSRF_SECRET_KEY` separately, that one). That is, §2's `SECRET_KEY` management is the foundation not only of the session but also of CSRF defense. Since one key plays two roles, the strictness of its generation, storage, and rotation is doubly important.

### 4.3 Embedding the token in templates

Enable `CSRFProtect` and a hidden field for the CSRF token becomes mandatory on forms. If you use `FlaskForm` (WTForms), `{{ form.csrf_token }}` outputs it. For a plain HTML form, embed it with the `csrf_token()` helper.

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

The token's field name can be controlled with `WTF_CSRF_FIELD_NAME` (default `'csrf_token'`), and the expiry with `WTF_CSRF_TIME_LIMIT` (default `3600` seconds).

### 4.4 AJAX: send with the `X-CSRFToken` header

In `fetch` / `XMLHttpRequest` from JavaScript, a hidden field can't be used, so send the token with the **HTTP header `X-CSRFToken`**. The standard is to embed the token in a `<meta>` tag and read it out from 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 Excluding API endpoints: understanding the boundary where CSRF "doesn't apply"

This is the most important as a design decision. **A stateless Bearer-token-authenticated API needs no CSRF protection in principle.** Let me pin down the reason accurately.

CSRF holds because "**the browser automatically attaches the authentication info (the cookie)**." Even if a POST is forced from the attacker's site, the attack gets through because the browser bundles the victim's cookie on its own. On the other hand, header authentication like `Authorization: Bearer <token>` **isn't automatically attached by the browser**. JS on the attacker's site can't read out the victim's token and attach it to another origin's API (due to the same-origin policy / CORS). That is, because the CSRF premise of "the browser's automatic sending" collapses, **adding a CSRF token to a token-auth API is meaningless double defense**.

Therefore, make `CSRFProtect` take effect on browser-facing forms protected by a cookie session, and exclude token-auth API endpoints with `@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 トークンは持てない）
    ...
```

> ⚠️ **"Exclusion" is not defenselessness.** `@csrf.exempt` only "doesn't do CSRF token verification," and the premise is that **you always stretch the corresponding authentication / authorization by another means**. For a token-auth API, verification of the `Authorization` header; for an external Webhook, signature verification of the sender (for Stripe, HMAC verification of the signature header). Exposing an excluded endpoint without authentication is putting the cart before the horse. Always separate "the reason CSRF is unnecessary (no automatic sending by the browser)" from "the reason authentication is unnecessary."

| Authentication method | CSRF protection | Reason |
|---|---|---|
| Cookie session (browser form) | **Needed** | The browser automatically sends the cookie, so the attack holds |
| `Authorization: Bearer` token | **Unnecessary** (`@csrf.exempt`) | The browser doesn't auto-send, and the CSRF premise collapses |
| External Webhook | Exclude + signature verification | Can't hold a token. Ensure authenticity with the sender's signature (HMAC) |

---

## **5. XSS and auto-escaping: the range Jinja protects, and the range it doesn't**

### 5.1 Jinja's auto-escaping rules

XSS (cross-site scripting) is an attack where a `<script>` and the like mixed into user input is executed as HTML as-is. Flask's template engine **Jinja has auto-escaping enabled by default**, and this is the first bulwark. Even if `<script>` comes into `{{ name }}`, it's converted to `&lt;script&gt;` and neutralized.

However, an easily-overlooked pitfall is that **auto-escaping takes effect only for templates with the extensions `.html` / `.htm` / `.xml` / `.xhtml`**.

| Template kind | Auto-escaping |
|---|---|
| `.html` / `.htm` / `.xml` / `.xhtml` files | ✅ Enabled (default) |
| Other extensions (`.txt`, etc.) | ❌ Disabled |
| **Templates loaded directly from a string** (`render_template_string`, etc.) | ❌ Disabled |

> ⚠️ **String templates aren't auto-escaped.** Passing user input as a template string, like `render_template_string(user_supplied)`, is doubly dangerous — auto-escaping is disabled, and it's a breeding ground for **SSTI (server-side template injection)**. Always hold templates as `.html` files, treat user input as **data (a value passed to `{{ var }}`)**, and don't make it the template itself.

### 5.2 `markupsafe`'s `escape` and `Markup`

When manual escaping is needed outside the template (a view function directly returning an HTML fragment, etc.), use `escape`. **As an important change, `escape` and `Markup` are now imported from `markupsafe`, not from `flask`** (previously you could also get them from `flask`, but now `markupsafe` is correct).

```python
from markupsafe import escape


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

Conversely, what declares "I guarantee this string is safe HTML" is `Markup`. This is a powerful and dangerous tool that **intentionally disables** auto-escaping.

```python
from markupsafe import Markup

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

> ⚠️ **Don't use `Markup()` on user input.** The official docs warn about this explicitly. `Markup(user_input)` means "treat user input as trusted HTML," an act of **disabling auto-escaping yourself and opening an XSS hole**. You may use `Markup` only on constant HTML you fully control. If you see code that passes a user-derived string through `Markup`, consider it a vulnerability.

### 5.3 XSS patterns auto-escaping can't prevent either

"There's Jinja's auto-escaping, so XSS is safe" is also wrong. The official documentation lists representative patterns auto-escaping doesn't protect.

- **Unquoted HTML attributes**: if you don't quote the attribute value, like `<a href={{ value }}>`, an attacker can mix whitespace or `onmouseover=...` into `value` and slip past escaping. **Always quote attribute values**: `<a href="{{ value }}">`.
- **`javascript:`-scheme URLs**: if `user_url` in `<a href="{{ user_url }}">` is `javascript:alert(1)`, a script runs on click. Attribute escaping can't prevent it. **Verify the URL's scheme (allow only `http`/`https`)**, or forbid `javascript:` with the CSP described later.
- **Applying `Markup()` to user data** (as in §5.2).

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

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

These are problems of "how you write the template," and the escaping feature can't fully cover them. That's exactly why we layer the **CSP of the next section as defense-in-depth**.

---

## **6. Security headers: adding what isn't added by default**

### 6.1 The headers you should add

Flask **doesn't add any security-related response headers by default.** HSTS, CSP, `nosniff`, and frame control are either added yourself or entrusted to an extension. The basic set you should add in production is this.

| Header | Recommended value | Role |
|---|---|---|
| `Strict-Transport-Security` | `max-age=31536000; includeSubDomains` | Make subsequent connections HTTPS-only (HSTS). Prevent downgrade attacks |
| `Content-Security-Policy` | `default-src 'self'` | Restrict the source of scripts, etc., defending XSS in depth |
| `X-Content-Type-Options` | `nosniff` | Forbid MIME sniffing, preventing execution from incorrect type interpretation |
| `X-Frame-Options` | `SAMEORIGIN` | Forbid iframe embedding from other sites, preventing clickjacking |

> 💡 **Don't add `X-XSS-Protection`.** The once-recommended `X-XSS-Protection` header is now **deprecated**, and the browser-side filter itself has been removed. It's not in the official recommendation list either. Old blogs and generative-AI output sometimes recommend it, but adding it has no effect and, depending on the setting, can conversely create a vulnerability, so **not adding it is correct**. Compose XSS defense with "Jinja auto-escaping + correct template writing + CSP."

### 6.2 Adding them with `after_request` (minimal implementation)

An implementation that adds headers to all responses with the `after_request` hook, without increasing external extensions. You can consolidate global side effects in one place, and what gets added is clear at a glance from the code.

```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 is meaningful only over HTTPS.** `Strict-Transport-Security` takes effect only once delivered over HTTPS. Add it over development plaintext HTTP and the browser remembers that host as "HTTPS-required," which can cause trouble locally. It's safe to add it only in production (behind TLS termination), or to branch on `app.debug`. CSP too — `default-src 'self'` is a strict starting point, so on a site using external CDNs, analytics tags, or inline scripts, tuning to **gradually loosen it to match your app** is needed (the standard is to first observe with `Content-Security-Policy-Report-Only`, then apply for real).

### 6.3 `after_request` vs. Flask-Talisman

For header addition, there's also the option of the dedicated extension **Flask-Talisman**. It takes care of HTTPS forced redirect, HSTS, CSP (including nonce generation), and frame control all at once.

| Aspect | `after_request` (manual) | Flask-Talisman |
|---|---|---|
| Added dependency | None (standard features only) | One extension added |
| Transparency | What gets added is obvious in the code | Defaults are implicit (check the docs) |
| CSP nonce | Needs self-implementation | Built-in support |
| HTTPS forced redirect | Self-built | Built-in |
| Suited case | A few headers, simple requirements | Complex requirements needing a CSP nonce or HTTPS forcing |

In the author's B2B SaaS, because the CSP was simple (own-origin-centric) and the requirements were fixed, I managed it with a few lines of `after_request` without increasing dependencies. It's a decision that **prioritized "knowing explicitly what's added" over "being made easier by an extension."** If requirements like needing a nonce in the CSP, or wanting to do HTTPS forced redirect at the Flask layer, come up, I migrate to Talisman. This is the practice of YAGNI — "fit it minimally to current requirements, and add when it becomes necessary."

---

## **7. DoS and resource limits: stop the "size" of input at the boundary too**

Security includes defense not only against "invalid values" but also against "**oversized requests**." Accepting huge bodies or enormous numbers of form parts without limit causes denial of service (DoS) from memory exhaustion. The Flask 3.1 series has mechanisms to stop this with settings.

| Config key | Default | Added | Role |
|---|---|---|---|
| `MAX_CONTENT_LENGTH` | `None` | — | The max byte count of the request body. Returns 413 if exceeded |
| `MAX_FORM_MEMORY_SIZE` | `500_000` (500kB) | 3.1 | The upper limit of the total size of non-file form values |
| `MAX_FORM_PARTS` | `1_000` | 3.1 | The upper limit of the number of parts in a multipart form |
| `TRUSTED_HOSTS` | `None` | 3.1 | Verify the Host header at routing time |

```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 ヘッダ攻撃対策
)
```

Three points to grasp.

- **`MAX_CONTENT_LENGTH`'s default is `None` (unlimited).** If you accept file uploads, **always set it explicitly**. In Flask 3.1, you can also set a per-endpoint limit with `Request.max_content_length`, enabling fine control like "large only for the upload route, small for others."
- **`MAX_FORM_MEMORY_SIZE` / `MAX_FORM_PARTS` got defaults in 3.1.** According to the official docs, the 3.1 defaults are designed so that **the memory a single form occupies stays at most about 500MB**. In a migration from an old version, behavior may change because these defaults were added, so apps handling huge forms should review the values.
- **Prevent Host header attacks with `TRUSTED_HOSTS` (3.1).** Code that trusts the `Host` header to generate a password-reset link issues a "reset link to an attacker domain" if a fake `Host` is sent. `TRUSTED_HOSTS` verifies `Host` at routing time and blocks this.

> 💡 **The app layer's limits and the WAF**: the DoS countermeasures so far limit "the size of one request," and **a DDoS where "a large number of requests come" is a separate-layer** problem. Rate limiting, IP reputation, and absorbing large-scale volume attacks should be done in front of the app (WAF / CDN / load balancer). The design of defense-in-depth combining the app layer's input validation and the front-stage WAF is summarized in the [defense-in-depth-with-WAF guide](/blog/waf-defense-in-depth-aws-waf-cloud-armor-owasp-guide). Position Flask's `MAX_*` settings as the last line of defense to stop oversized input "that slipped past the front stage, or that's from an authenticated user."

---

## **8. Summary and a Flask security checklist**

Flask security is decided by the accurate separation of the framework's "smart defaults" and "the boundaries you explicitly harden." Let me re-list the key points of this article.

1. **`session` is a signed client cookie.** It protects integrity (tamper detection) but **not confidentiality** — the contents are readable. Put secrets and large data in a server-side session.
2. **`SECRET_KEY` is the root of all signing.** Generate it with `secrets.token_hex()` and inject via an environment variable. Zero-downtime key rotation with 3.1's `SECRET_KEY_FALLBACKS`.
3. **Harden production cookies with `SECURE` / `HttpOnly` / `SameSite='Lax'`**, and mitigate fixation with `session.clear()` + regeneration at login, and replay attacks with a shortened `PERMANENT_SESSION_LIFETIME`.
4. **CSRF isn't in the core** (an official design decision). `init_app` Flask-WTF's `CSRFProtect` in the factory, and protect with a template token and AJAX's `X-CSRFToken`. **A stateless token-auth API is `@csrf.exempt`** (because the CSRF premise doesn't hold).
5. **For XSS, Jinja auto-escaping** (`.html`, etc.) is the default bulwark. `escape` / `Markup` come from **`markupsafe`**. Unquoted attributes, `javascript:` URLs, and `Markup(user input)` can't be prevented, so defend in depth with CSP.
6. **Security headers aren't added by default.** Add HSTS / CSP / `nosniff` / `X-Frame-Options` with `after_request` or Flask-Talisman (don't add `X-XSS-Protection`, as it's deprecated).
7. **For DoS, stop the "size" of input too.** `MAX_CONTENT_LENGTH` / `MAX_FORM_MEMORY_SIZE` / `MAX_FORM_PARTS` / `TRUSTED_HOSTS` (3.1). A large number of requests, with a front-stage WAF.

Finally, a checklist to pass before going to production. I've organized the items the author actually checks in a multi-tenant B2B SaaS.

| Category | Check item | Confirm |
|---|---|---|
| Key | `SECRET_KEY` is generated with `secrets.token_hex()` and injected from an environment variable | ☐ |
| Key | `SECRET_KEY` remains nowhere in code, repository, or logs | ☐ |
| Key | A key-rotation procedure (`SECRET_KEY_FALLBACKS`) is prepared in operations | ☐ |
| Cookie | `SESSION_COOKIE_SECURE=True` in production | ☐ |
| Cookie | `SESSION_COOKIE_HTTPONLY=True` / `SESSION_COOKIE_SAMESITE='Lax'` | ☐ |
| Session | `session.clear()` + regeneration at login, `session.clear()` at logout | ☐ |
| Session | No secrets / large data put in `session` (ID only) | ☐ |
| Session | `PERMANENT_SESSION_LIFETIME` is shortened to match requirements | ☐ |
| CSRF | `CSRFProtect` is in effect on browser forms | ☐ |
| CSRF | Token-auth API / Webhook is `@csrf.exempt` and authenticated by another means (Bearer / signature) | ☐ |
| XSS | Templates are `.html` files; `render_template_string(user input)` is not used | ☐ |
| XSS | Attribute values are quoted, URL schemes are verified, and there's no `Markup(user input)` | ☐ |
| Header | HSTS / CSP / `nosniff` / `X-Frame-Options` added (don't add `X-XSS-Protection`) | ☐ |
| DoS | `MAX_CONTENT_LENGTH` set explicitly, `Host` verified with `TRUSTED_HOSTS` (3.1) | ☐ |
| Authorization | Authentication (who) and authorization (what may be done) separated, with authorization matched against the DB | ☐ |

Session, cookies, CSRF, XSS, headers — these are the work of hardening the boundary of "external input coming into the app" and "output the app returns." How declaratively and by-setting you can protect the boundary preemptively stops tampering like `session['role']='admin'` and mass assignment like `{"role":"admin"}` (the [REST API boundary design with marshmallow](/blog/marshmallow-flask-sqlalchemy-rest-api-production-guide) explains the technique of structurally preventing it with `load_only` / `dump_only`).

For the overall picture of Flask security and its connection to other design targets (structure, context, deployment, testing, observability), go back to the [Flask production guide](/blog/flask-production-guide) for the overview. And the authorization design of "an authenticated user can access only permitted data" is split into the [multi-tenant SaaS data-isolation / authorization design guide](/blog/multi-tenant-saas-data-isolation-authorization-design-guide) as a topic one level above the session. Hardening the boundary one by one with the structure of settings and code — that is the Flask security implementation that underpins the business's reliability.
