Skip to content

Tutorial: Add CSRF Protection to Forms

In this tutorial you will add CSRF protection to a Remix application with forms, generate tokens for both HTML forms and JavaScript API requests, and handle validation errors.

Prerequisites

Step 1: Add the CSRF Middleware

The CSRF middleware depends on sessions to store the token. Add it after the session middleware:

ts
import { createRouter } from 'remix/fetch-router'
import { session } from 'remix/session-middleware'
import { csrf, getCsrfToken } from 'remix/csrf-middleware'
import { formData } from 'remix/form-data-middleware'
import { route } from 'remix/fetch-router/routes'

let routes = route({
  newPost: '/posts/new',
  createPost: '/posts',
})

let router = createRouter({
  middleware: [
    session(sessionCookie, sessionStorage),
    csrf(),
    formData(),
  ],
})

The middleware order matters:

  1. session() -- makes the session available.
  2. csrf() -- generates and validates tokens using the session.
  3. formData() -- parses form bodies so CSRF can read the token field.

Step 2: Embed the Token in Forms

Use getCsrfToken() to retrieve the token and include it as a hidden field:

ts
import { getCsrfToken } from 'remix/csrf-middleware'

router.get(routes.newPost, ({ context }) => {
  let token = getCsrfToken(context)

  return html`
    <form method="post" action="/posts">
      <input type="hidden" name="_csrf" value="${token}" />

      <label for="title">Title</label>
      <input type="text" id="title" name="title" required />

      <label for="body">Body</label>
      <textarea id="body" name="body" required></textarea>

      <button type="submit">Create Post</button>
    </form>
  `
})

The hidden <input> with name="_csrf" is what the middleware checks on submission.

Step 3: Handle Form Submissions

Your POST handler does not need any CSRF-specific code. The middleware validates the token before your handler runs:

ts
import { FormData } from 'remix/form-data-middleware'

router.post(routes.createPost, async ({ context }) => {
  // If we reach here, the CSRF token is valid
  let data = context.get(FormData)
  let title = data.get('title')
  let body = data.get('body')

  await db.create(posts, { title, body })
  return redirect('/posts')
})

If the token is missing or invalid, the middleware returns a 403 Forbidden response and your handler never executes.

Step 4: Protect JavaScript API Requests

For requests made with fetch() from the client, you can send the token in a header instead of a form field. First, expose the token in your page's HTML:

ts
router.get(routes.app, ({ context }) => {
  let token = getCsrfToken(context)

  return html`
    <html>
      <head>
        <meta name="csrf-token" content="${token}" />
      </head>
      <body>
        <div id="app"></div>
        <script src="/app.js"></script>
      </body>
    </html>
  `
})

Then read it from the client and include it in fetch requests:

js
// client-side JavaScript
let csrfToken = document.querySelector('meta[name="csrf-token"]').content

async function createPost(title, body) {
  let response = await fetch('/posts', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken,
    },
    body: JSON.stringify({ title, body }),
  })
  return response.json()
}

The middleware checks both the _csrf form field and the X-CSRF-Token header. Either one is sufficient.

Step 5: Customize Field and Header Names

If your application uses different conventions (e.g., Rails-style authenticity_token):

ts
csrf({
  fieldName: 'authenticity_token',
  headerName: 'X-Authenticity-Token',
})

Update your forms and client code to match:

html
<input type="hidden" name="authenticity_token" value="${token}" />

Step 6: Test CSRF Protection

Verify that requests without a valid token are rejected:

sh
# This should return 403 Forbidden (no token)
curl -X POST http://localhost:3000/posts \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "title=Hello&body=World"

# This should also return 403 (wrong token)
curl -X POST http://localhost:3000/posts \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "title=Hello&body=World&_csrf=invalid-token"

Both requests should fail with 403. Only requests with a valid token from a real session succeed.

What You Learned

  • How to add CSRF middleware after the session middleware.
  • How to embed CSRF tokens in HTML forms.
  • How to send CSRF tokens from JavaScript via headers.
  • How to customize field and header names.
  • How to verify CSRF protection is working.

Next Steps

Released under the MIT License.