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.