Skip to content

Security

This guide covers the essential security practices for Remix V3 applications. Security is not a feature you add at the end -- it should be part of every decision, from how you handle form submissions to how you deploy.

CSRF Protection

Cross-Site Request Forgery (CSRF) is an attack where a malicious website tricks a user's browser into making a request to your application. Because the browser automatically sends cookies, the request appears to come from the logged-in user.

Example: a malicious page includes a hidden form that submits to your /transfer-money endpoint. If the user is logged in to your site, the browser sends their session cookie, and the transfer goes through.

Setting Up CSRF Middleware

Remix provides the csrf middleware to prevent CSRF attacks:

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

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

The middleware does two things:

  1. On GET requests -- Generates a unique CSRF token and stores it in the session.
  2. On POST/PUT/PATCH/DELETE requests -- Validates that the request includes the correct token.

Including the Token in Forms

Add a hidden field to your forms with the CSRF token:

ts
import { Session } from 'remix/session'

router.map(formRoute.index, ({ context }) => {
  let session = context.get(Session)
  let csrfToken = session.get('csrfToken')

  return html`
    <form method="post">
      <input type="hidden" name="_csrf" value="${csrfToken}" />
      <input type="text" name="title" />
      <button type="submit">Submit</button>
    </form>
  `
})

CSRF for API Requests

For JavaScript-initiated requests (fetch/XHR), send the token in a header:

ts
// The csrf middleware also checks the X-CSRF-Token header
fetch('/api/posts', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken,
  },
  body: JSON.stringify({ title: 'Hello' }),
})

SameSite cookies provide partial protection

Setting sameSite: 'Lax' on your session cookie prevents CSRF for most cases, because the cookie is not sent with cross-origin POST requests. However, CSRF tokens provide defense in depth and protect against edge cases.

CORS Configuration

Cross-Origin Resource Sharing (CORS) controls which websites can make requests to your API from a browser. Without CORS headers, browsers block cross-origin requests by default.

Setting Up CORS Middleware

ts
import { cors } from 'remix/cors-middleware'

let router = createRouter({
  middleware: [
    cors({
      origin: 'https://myapp.com',         // Allowed origin(s)
      methods: ['GET', 'POST', 'PUT', 'DELETE'],
      allowedHeaders: ['Content-Type', 'Authorization'],
      credentials: true,                    // Allow cookies
      maxAge: 86400,                        // Cache preflight for 24 hours
    }),
  ],
})

Multiple Origins

ts
cors({
  origin: ['https://myapp.com', 'https://admin.myapp.com'],
})

Dynamic Origins

For applications with many origins or per-tenant domains:

ts
cors({
  origin(requestOrigin) {
    // Allow any subdomain of myapp.com
    if (requestOrigin && requestOrigin.endsWith('.myapp.com')) {
      return requestOrigin
    }
    return false
  },
})

Never use origin: '*' with credentials

Setting origin: '*' (allow all origins) with credentials: true is not allowed by browsers and will cause requests to fail. If you need cookies, specify exact origins.

CORS for Public APIs

If you are building a public API that anyone can call:

ts
cors({
  origin: '*',
  methods: ['GET', 'POST'],
  allowedHeaders: ['Content-Type'],
  credentials: false,
})

Cross-Origin Protection (COP)

The cop middleware provides additional cross-origin protections by setting security headers:

ts
import { cop } from 'remix/cop-middleware'

let router = createRouter({
  middleware: [
    cop(),
  ],
})

This sets the following headers:

HeaderValuePurpose
Cross-Origin-Opener-Policysame-originPrevents other windows from accessing your window object
Cross-Origin-Embedder-Policyrequire-corpPrevents loading cross-origin resources without explicit permission
Cross-Origin-Resource-Policysame-originPrevents other sites from embedding your resources

When to use COP

COP headers are recommended for applications that handle sensitive data. They can break features that rely on cross-origin embedding (like third-party iframes or loading images from CDNs). Test thoroughly before enabling in production.

Cookies are the foundation of session management. Configuring them correctly is critical.

ts
import { createCookie } from 'remix/cookie'

let sessionCookie = createCookie('__session', {
  httpOnly: true,          // JavaScript cannot read the cookie
  secure: true,            // Only sent over HTTPS
  sameSite: 'Lax',         // Not sent with cross-origin POST requests
  secrets: [process.env.SESSION_SECRET!],  // Sign the cookie
  maxAge: 60 * 60 * 24 * 7,  // Expires in 7 days
  path: '/',               // Available on all paths
})
OptionRecommendedPurpose
httpOnlytruePrevents XSS attacks from reading the cookie via document.cookie
securetrue in productionPrevents cookies from being sent over unencrypted HTTP
sameSite'Lax' or 'Strict'Prevents CSRF by restricting cross-origin cookie sending
secretsLong, random stringSigns the cookie so tampering is detected
maxAgeReasonable durationLimits how long a session lasts
path'/'Controls which paths include the cookie

The secrets array is used to sign cookies. This means the server can detect if someone modified the cookie value. Use a long, random string:

bash
# Generate a secret
node -e "console.log(crypto.randomUUID() + crypto.randomUUID())"

You can rotate secrets by adding a new secret to the beginning of the array. The first secret is used for signing new cookies, and all secrets are tried when verifying:

ts
secrets: [
  process.env.NEW_SESSION_SECRET!,  // Used for new cookies
  process.env.OLD_SESSION_SECRET!,  // Still accepted for existing cookies
],

Session Security

Regenerate Session IDs

After login or privilege escalation, regenerate the session ID to prevent session fixation attacks:

ts
router.map(loginRoutes.action, async ({ context }) => {
  let result = await verifyCredentials(credentialsAuth, context)
  if (!result) return showLoginForm('Invalid credentials.')

  let session = context.get(Session)
  session.regenerateId()  // New session ID, same data
  session.set('userId', result.id)

  return new Response(null, { status: 302, headers: { Location: '/' } })
})

Destroy Sessions on Logout

Always destroy the session on logout, do not just clear the user ID:

ts
router.map(logoutRoute, async ({ context }) => {
  let session = context.get(Session)
  session.destroy()  // Removes the session data entirely

  return new Response(null, { status: 302, headers: { Location: '/' } })
})

Session Expiry

Set reasonable expiration times. Long-lived sessions increase the window for session hijacking:

ts
// Short sessions for sensitive applications
let sessionCookie = createCookie('__session', {
  maxAge: 60 * 60,  // 1 hour
  // ...
})

// Longer sessions with "remember me"
let maxAge = rememberMe
  ? 60 * 60 * 24 * 30  // 30 days
  : 60 * 60 * 24       // 1 day

Production Session Storage

Use Redis or Memcache for production sessions instead of filesystem storage:

ts
import { createRedisSessionStorage } from 'remix/session-storage-redis'

let sessionStorage = createRedisSessionStorage({
  url: process.env.REDIS_URL!,
})

This ensures sessions work across multiple server instances and survive server restarts.

HTML Escaping

The html template tag from remix/html-template automatically escapes interpolated values to prevent Cross-Site Scripting (XSS) attacks:

ts
import { html } from 'remix/html-template'

let userInput = '<script>alert("xss")</script>'

// Safe -- the html tag escapes the value
return html`<p>${userInput}</p>`
// Output: <p>&lt;script&gt;alert("xss")&lt;/script&gt;</p>

Raw HTML

If you need to insert raw HTML (like rendered Markdown), use SafeHtml:

ts
import { html, SafeHtml } from 'remix/html-template'

let renderedMarkdown = markdownToHtml(userContent) // You trust this output
return html`<div>${new SafeHtml(renderedMarkdown)}</div>`

Only use SafeHtml with content you have already sanitized. Never use it with raw user input.

Common XSS Vectors

Be cautious with:

ts
// URLs -- validate before using in href
let url = userInput
if (!url.startsWith('http://') && !url.startsWith('https://')) {
  url = '#'  // Prevent javascript: URLs
}
return html`<a href="${url}">Link</a>`

// Attributes -- the html tag handles escaping
return html`<div title="${userInput}">Content</div>`

// JSON in script tags -- use JSON.stringify, not template interpolation
return html`
  <script>
    let data = ${new SafeHtml(JSON.stringify(userData))}
  </script>
`

Input Validation

Validate all input on the server. Client-side validation is a convenience for users but provides no security.

Validate Before Using

ts
import { object, string, number, parseSafe } from 'remix/data-schema'
import { email, minLength, min, max } from 'remix/data-schema/checks'

let CreateUserSchema = object({
  name: string().pipe(minLength(1), maxLength(100)),
  email: string().pipe(email()),
  age: number().pipe(min(0), max(200)),
})

router.map(createUserRoute, async ({ context }) => {
  let body = await request.json()
  let result = parseSafe(CreateUserSchema, body)

  if (!result.success) {
    return new Response(JSON.stringify({ errors: result.issues }), {
      status: 422,
    })
  }

  // result.value is validated and typed
  await db.create(users, result.value)
})

Validate URL Parameters

Do not trust URL parameters to be well-formed:

ts
router.map(userRoute, async ({ params }) => {
  let userId = Number(params.id)

  if (!Number.isInteger(userId) || userId <= 0) {
    return new Response('Invalid user ID', { status: 400 })
  }

  let user = await db.findOne(users, {
    where: eq(users.columns.id, userId),
  })

  if (!user) {
    return new Response('User not found', { status: 404 })
  }

  // ...
})

Prevent SQL Injection

Remix's data-table package parameterizes all queries automatically. SQL injection is only a risk with raw SQL:

ts
// Safe -- parameterized automatically
let user = await db.findOne(users, {
  where: eq(users.columns.email, userInput),
})

// Safe -- sql`` parameterizes values
let results = await db.execute(sql`SELECT * FROM users WHERE email = ${userInput}`)

// DANGEROUS -- never do this
let results = await db.execute(rawSql(`SELECT * FROM users WHERE email = '${userInput}'`))

File Upload Security

See the File Uploads Guide for detailed coverage. Key points:

  • Validate file type on the server -- do not trust Content-Type or file extensions
  • Enforce size limits to prevent denial-of-service
  • Generate unique filenames instead of using the original filename
  • Serve files through a route handler with explicit Content-Type and X-Content-Type-Options: nosniff
  • Never serve the uploads directory as static files
ts
// Always set these headers when serving user-uploaded files
return new Response(file.stream(), {
  headers: {
    'Content-Type': file.type,
    'X-Content-Type-Options': 'nosniff',
    'Content-Disposition': 'inline',
  },
})

Environment Variables

Never hardcode secrets in your source code:

ts
// Wrong
let cookie = createCookie('__session', {
  secrets: ['my-secret-key'],
})

// Right
let cookie = createCookie('__session', {
  secrets: [process.env.SESSION_SECRET!],
})

Essential environment variables for production:

VariablePurpose
SESSION_SECRETSigns session cookies
DATABASE_URLDatabase connection string
NODE_ENVproduction enables production optimizations
GOOGLE_CLIENT_SECRETOAuth provider secret
AWS_SECRET_ACCESS_KEYS3 storage credentials

Never commit .env files

Add .env to your .gitignore. Use your hosting provider's secrets management instead:

# .gitignore
.env
.env.local
.env.production

HTTPS in Production

Always use HTTPS in production. Without it, cookies, passwords, and all request data are transmitted in plain text.

Reverse Proxy with Nginx

The most common setup is a reverse proxy that handles TLS:

nginx
server {
    listen 443 ssl http2;
    server_name myapp.com;

    ssl_certificate /etc/letsencrypt/live/myapp.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/myapp.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

# Redirect HTTP to HTTPS
server {
    listen 80;
    server_name myapp.com;
    return 301 https://$host$request_uri;
}

Strict Transport Security

Tell browsers to always use HTTPS:

ts
// In your middleware
function hstsMiddleware(context, next) {
  let response = await next()
  response.headers.set(
    'Strict-Transport-Security',
    'max-age=31536000; includeSubDomains',
  )
  return response
}

Content Security Policy

A Content Security Policy (CSP) tells the browser which resources are allowed to load. This prevents XSS attacks even if an attacker manages to inject a script tag.

ts
function cspMiddleware(context, next) {
  let response = await next()
  response.headers.set(
    'Content-Security-Policy',
    [
      "default-src 'self'",
      "script-src 'self'",
      "style-src 'self' 'unsafe-inline'",  // Required for css() mixin
      "img-src 'self' data: https:",
      "font-src 'self'",
      "connect-src 'self'",
      "frame-src 'none'",
      "object-src 'none'",
      "base-uri 'self'",
      "form-action 'self'",
    ].join('; '),
  )
  return response
}

CSP and the css() mixin

The css() mixin injects styles via a <style> tag, which requires 'unsafe-inline' in the style-src directive. For stricter CSP, you can use nonce-based inline styles or move styles to static CSS files.

Rate Limiting

Protect your application from brute-force attacks and abuse:

ts
// Simple in-memory rate limiter
function rateLimit(options: { max: number; windowMs: number }) {
  let requests = new Map<string, { count: number; resetAt: number }>()

  return async (context: any, next: () => Promise<Response>) => {
    let ip = context.request.headers.get('X-Forwarded-For') || 'unknown'
    let now = Date.now()

    let record = requests.get(ip)
    if (!record || now > record.resetAt) {
      record = { count: 0, resetAt: now + options.windowMs }
      requests.set(ip, record)
    }

    record.count++

    if (record.count > options.max) {
      return new Response('Too many requests', {
        status: 429,
        headers: {
          'Retry-After': String(Math.ceil((record.resetAt - now) / 1000)),
        },
      })
    }

    return next()
  }
}

Apply it to sensitive routes:

ts
// Strict limit on login attempts
router.map(loginRoutes.action, {
  middleware: [rateLimit({ max: 5, windowMs: 15 * 60 * 1000 })],  // 5 attempts per 15 minutes
}, loginHandler)

Use Redis for distributed rate limiting

The in-memory rate limiter above only works for a single server instance. For multiple servers, use Redis to store counters.

Security Checklist

Use this checklist before deploying to production:

Authentication

  • [ ] Passwords are hashed with bcrypt or argon2
  • [ ] Session IDs are regenerated after login
  • [ ] Sessions are destroyed on logout
  • [ ] Login has rate limiting
  • [ ] OAuth state parameter is validated

Cookies

  • [ ] httpOnly: true on session cookies
  • [ ] secure: true in production
  • [ ] sameSite: 'Lax' or 'Strict'
  • [ ] Secrets are long, random strings from environment variables

Input

  • [ ] All input is validated on the server with data-schema
  • [ ] File uploads are validated for type and size
  • [ ] URL parameters are validated before use
  • [ ] SQL queries use parameterized values (never string concatenation)

Output

  • [ ] HTML is escaped with the html template tag
  • [ ] User-uploaded files are served with X-Content-Type-Options: nosniff
  • [ ] JSON is serialized with JSON.stringify, not template concatenation

Headers

  • [ ] CSRF tokens on all forms
  • [ ] CORS configured for specific origins
  • [ ] Strict-Transport-Security header set
  • [ ] Content-Security-Policy header set

Infrastructure

  • [ ] HTTPS everywhere
  • [ ] Secrets in environment variables, not in code
  • [ ] .env in .gitignore
  • [ ] Dependencies kept up to date

Released under the MIT License.