Skip to content

CSRF Protection

If your site logs users in with a cookie, any other website can make a visitor's browser post to your forms, and the browser sends the cookie along. That's cross-site request forgery (CSRF). The defense is a secret token: your form carries it in a hidden field, and your route refuses any submission that doesn't send it back.

pydantic-schemaforms renders the hidden field. Your app creates the token, keeps it in the session and checks it, because only your app knows its sessions.

import hmac
import secrets

from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse
from starlette.middleware.sessions import SessionMiddleware

from pydantic_schemaforms import Field, FormModel, html, render_form_html_async


class TransferForm(FormModel):
    to_account: str = Field(title='To account')
    amount: int = Field(title='Amount', ge=1)


app = FastAPI()
app.add_middleware(SessionMiddleware, secret_key='change-me')  # load from your settings


def issue_csrf_token(request: Request) -> str:
    token = secrets.token_urlsafe(32)
    request.session['csrf_token'] = token
    return token


def csrf_token_is_valid(request: Request, submitted: object) -> bool:
    expected = request.session.get('csrf_token')
    return bool(expected and isinstance(submitted, str)) and hmac.compare_digest(
        expected, submitted
    )


def form_options(request: Request) -> dict:
    return {
        'submit_url': '/transfer',
        'csrf_mode': 'required-provider',
        'csrf_token_provider': issue_csrf_token(request),
        'include_framework_assets': True,
        'asset_mode': 'cdn',
    }


@app.get('/transfer', response_class=HTMLResponse)
async def show_transfer(request: Request):
    return await render_form_html_async(TransferForm, **form_options(request))


@app.post('/transfer', response_class=HTMLResponse)
async def submit_transfer(request: Request):
    form = await request.form()
    if not csrf_token_is_valid(request, form.get('csrf_token')):
        raise HTTPException(status_code=403, detail='Invalid or expired form; reload the page.')

    result = TransferForm.validate(form, **form_options(request))
    if not result.is_valid:
        return await result.render_with_errors_async()

    amount = result.data['amount']
    return html(t'<p>Transferred {amount}.</p>')
import hmac
import secrets

from flask import Flask, abort, request, session

from pydantic_schemaforms import Field, FormModel, html, render_form_html


class TransferForm(FormModel):
    to_account: str = Field(title='To account')
    amount: int = Field(title='Amount', ge=1)


app = Flask(__name__)
app.secret_key = 'change-me'  # load from your settings


def issue_csrf_token() -> str:
    token = secrets.token_urlsafe(32)
    session['csrf_token'] = token
    return token


def csrf_token_is_valid(submitted: object) -> bool:
    expected = session.get('csrf_token')
    return bool(expected and isinstance(submitted, str)) and hmac.compare_digest(
        expected, submitted
    )


def form_options() -> dict:
    return {
        'submit_url': '/transfer',
        'csrf_mode': 'required-provider',
        'csrf_token_provider': issue_csrf_token(),
        'include_framework_assets': True,
        'asset_mode': 'cdn',
    }


@app.get('/transfer')
def show_transfer():
    return render_form_html(TransferForm, **form_options())


@app.post('/transfer')
def submit_transfer():
    if not csrf_token_is_valid(request.form.get('csrf_token')):
        abort(403, 'Invalid or expired form; reload the page.')

    result = TransferForm.validate(request.form, **form_options())
    if not result.is_valid:
        return result.render_with_errors()

    amount = result.data['amount']
    return html(t'<p>Transferred {amount}.</p>')

How it works

  1. issue_csrf_token() makes a random token and stores it in the session.
  2. csrf_mode="required-provider" with csrf_token_provider= renders it as a hidden csrf_token field. The required-provider mode refuses to render without a token, so a missing one is caught at once instead of silently shipping an unprotected form.
  3. The POST route checks the token first, with hmac.compare_digest so the comparison takes the same time whatever the input, and answers 403 when it doesn't match.
  4. validate() gets the same options, so a re-rendered form carries a new token. The token isn't a model field, so it never appears in result.data.

When you need it

You need CSRF protection for forms that change something (create, update, delete, buy) on a site that identifies users by cookie. You usually don't for APIs that authenticate with an Authorization header instead of cookies, or for read-only pages.

Checklist

  • Keep the session secret (secret_key) out of your code, and long and random.
  • Serve the site over HTTPS.
  • Never log the token values.
  • Use required-provider; csrf_field_name= changes the hidden field's name if your app already uses a different one.
Other CSRF modes

csrf_mode also accepts "off" (the default: no hidden field) and "field-only", which renders the field without requiring a provider. It exists for debugging and is only allowed together with debug=True. CSRFMode.REQUIRED_PROVIDER and the other enum values work the same as the strings.

That's the end of the tutorial. You can now build, validate, arrange, style and protect forms. From here:

  • the Recipes have complete forms to copy;
  • the Advanced User Guide covers live validation, data tables, JSON APIs and more;
  • the Reference lists every input, layout and option.