◀ 4.

Forbidden (403) CSRF verification failed. Request aborted.

▶
この記事の要点
  • Django の CSRF 対策(CsrfViewMiddleware)が POST リクエストを拒否したときに出る 403 エラー
  • 一番多い原因は、テンプレートの form 内に {% csrf_token %} を書き忘れていること
  • Ajax / fetch の POST では X-CSRFToken ヘッダーでトークンを送る必要がある
  • Django 4.0 以降、HTTPS やリバースプロキシ配下では CSRF_TRUSTED_ORIGINS の設定漏れでも発生する
  • DEBUG=True のエラー画面の「Reason given for failure」を見れば原因を絞り込める

エラー内容

Django でフォームを送信(POST)すると、次の画面が表示されて処理が止まります。

Forbidden (403)
CSRF verification failed. Request aborted.

CSRF(クロスサイトリクエストフォージェリ)は、別サイトから利用者のブラウザを使って勝手に POST させる攻撃です。Django は標準で有効な CsrfViewMiddleware により、POST・PUT・PATCH・DELETE のリクエストに正しいトークンが付いているかを検査し、付いていなければこのエラーで拒否します。GET では発生しません。

まず原因を確認する

settings.py で DEBUG = True の状態なら、エラー画面の下部に Reason given for failure として拒否理由が表示されます。主なものは次のとおりです。

表示される理由主な原因対処
CSRF token missing.フォームに {% csrf_token %} がない、Ajax でヘッダーを送っていない対処 1・2
CSRF cookie not set.ブラウザに csrftoken クッキーがない(クッキーのブロック、HTTPS 専用設定を HTTP で使っている等)対処 4
Origin checking failed - ... does not match any trusted origins.送信元のオリジンが信頼済みとして登録されていない対処 3
CSRF token incorrect / has incorrect length 等古い画面から送信した、トークンの値が壊れている画面を再読み込みして再送信

対処 1: form に {% csrf_token %} を書く

テンプレート側の form タグ内に csrf_token を設定していないことが、最も多い原因です。POST するすべての form に入れます。

<form method="post" action="{% url 'contact' %}">
    {% csrf_token %}
    {{ form.as_p }}
    <button type="submit">送信</button>
</form>

{% csrf_token %} は、トークンを入れた隠しフィールド(<input type="hidden" name="csrfmiddlewaretoken" ...>)に展開されます。ブラウザの「ページのソースを表示」でこの行が出力されているか確認してください。

書いたのに空になる場合

ビューでテンプレートを描画するときに request を渡していないと、トークンが出力されません。render() を使えば自動で渡されます。

from django.shortcuts import render

def contact(request):
    # 第 1 引数に request を渡すことでトークンが使えるようになる
    return render(request, "contact.html", {"form": form})

対処 2: Ajax / fetch ではヘッダーで送る

JavaScript から POST する場合は、クッキー csrftoken の値を X-CSRFToken ヘッダーに入れて送ります。

function getCookie(name) {
  const m = document.cookie.match('(^|;)\\s*' + name + '=([^;]*)');
  return m ? decodeURIComponent(m[2]) : null;
}

fetch("/api/save/", {
  method: "POST",
  headers: {
    "X-CSRFToken": getCookie("csrftoken"),
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ name: "test" }),
});

ページ内に一度も {% csrf_token %} がないとクッキーが発行されないことがあります。その場合はビューに @ensure_csrf_cookie デコレーターを付けます。詳しくは Django で CSRF トークンを Ajax 送信する方法 を参照してください。

対処 3: CSRF_TRUSTED_ORIGINS を設定する(HTTPS・プロキシ配下)

Django 4.0 以降は、リクエストの Origin ヘッダーも検査します。nginx などのリバースプロキシ経由で HTTPS 公開している場合や、別ドメインのフロントエンドから送信している場合は、信頼するオリジンをスキーム付きで登録します。

# settings.py
CSRF_TRUSTED_ORIGINS = [
    "https://example.com",
    "https://www.example.com",
]

Django 3.x までの書き方("example.com" のようにスキームなし)のまま 4.0 以降にアップグレードすると、このエラーが出るようになります。プロキシ配下では、プロキシが X-Forwarded-Proto を渡し、settings.py の SECURE_PROXY_SSL_HEADER でそれを信頼する設定も確認してください。

対処 4: CSRF クッキーが届いているか確認する

  • CSRF_COOKIE_SECURE = True のまま HTTP(http://localhost など)でアクセスすると、クッキーが送られず「CSRF cookie not set.」になります。開発環境では False にします
  • ブラウザのクッキーブロック設定や、iframe 内での表示(サードパーティクッキー扱い)でも発生します
  • ブラウザの開発者ツールの「Application(ストレージ)」→「Cookies」で csrftoken があるか確認します

やってはいけない対処

エラーを消すために settings.py の MIDDLEWARE から CsrfViewMiddleware を削除したり、すべてのビューに @csrf_exempt を付けたりするのは避けてください。CSRF 攻撃に無防備になります。@csrf_exempt は、外部サービスからの Webhook 受信など、署名検証など別の手段で正当性を確認できるビューに限って使います。

確認方法

  1. ページのソースに csrfmiddlewaretoken の hidden フィールドがあるか
  2. 開発者ツールの Network タブで、POST リクエストにトークン(フォームデータまたは X-CSRFToken ヘッダー)と csrftoken クッキーが付いているか
  3. DEBUG=True のエラー画面の Reason が変わったか(原因が 1 つ解消されると別の理由に変わることがある)

関連

子ページ

子ページはありません

同階層のページ
  1. Invalid HTTP_HOST header: '...'. You may need to add '...' to ALLOWED_HOSTS
  2. CommandError: You must set settings.ALLOWED_HOSTS if DEBUG is False.
  3. django.utils.datastructures.MultiValueDictKeyError
  4. Forbidden (403) CSRF verification failed. Request aborted.
  5. ModuleNotFoundError: No module named 'MySQLdb'
  6. WARNINGS: ?: (mysql.W002) MySQL Strict Mode is not set for database connection
  7. Unknown column 'table_name.id' in 'field list'
  8. RuntimeError: Model class ~ doesn't declare an explicit app_label and isn't in an application in INSTALLED_APPS.
  9. get() returned more than one MynumberRegist -- it returned 2!
  10. django.db.utils.OperationalError: (2006, "Can't connect to MySQL server")
  11. 'include' is not defined