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:
import { csrf } from 'remix/csrf-middleware'
let router = createRouter({
middleware: [
session(sessionCookie, sessionStorage),
csrf(),
],
})The middleware does two things:
- On GET requests -- Generates a unique CSRF token and stores it in the session.
- 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:
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:
// 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
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
cors({
origin: ['https://myapp.com', 'https://admin.myapp.com'],
})Dynamic Origins
For applications with many origins or per-tenant domains:
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:
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:
import { cop } from 'remix/cop-middleware'
let router = createRouter({
middleware: [
cop(),
],
})This sets the following headers:
| Header | Value | Purpose |
|---|---|---|
Cross-Origin-Opener-Policy | same-origin | Prevents other windows from accessing your window object |
Cross-Origin-Embedder-Policy | require-corp | Prevents loading cross-origin resources without explicit permission |
Cross-Origin-Resource-Policy | same-origin | Prevents 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.
Cookie Security
Cookies are the foundation of session management. Configuring them correctly is critical.
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
})Cookie Options Explained
| Option | Recommended | Purpose |
|---|---|---|
httpOnly | true | Prevents XSS attacks from reading the cookie via document.cookie |
secure | true in production | Prevents cookies from being sent over unencrypted HTTP |
sameSite | 'Lax' or 'Strict' | Prevents CSRF by restricting cross-origin cookie sending |
secrets | Long, random string | Signs the cookie so tampering is detected |
maxAge | Reasonable duration | Limits how long a session lasts |
path | '/' | Controls which paths include the cookie |
Cookie Signing
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:
# 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:
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:
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:
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:
// 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 dayProduction Session Storage
Use Redis or Memcache for production sessions instead of filesystem storage:
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:
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><script>alert("xss")</script></p>Raw HTML
If you need to insert raw HTML (like rendered Markdown), use SafeHtml:
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:
// 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
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:
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:
// 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-Typeor 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-TypeandX-Content-Type-Options: nosniff - Never serve the uploads directory as static files
// 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:
// Wrong
let cookie = createCookie('__session', {
secrets: ['my-secret-key'],
})
// Right
let cookie = createCookie('__session', {
secrets: [process.env.SESSION_SECRET!],
})Essential environment variables for production:
| Variable | Purpose |
|---|---|
SESSION_SECRET | Signs session cookies |
DATABASE_URL | Database connection string |
NODE_ENV | production enables production optimizations |
GOOGLE_CLIENT_SECRET | OAuth provider secret |
AWS_SECRET_ACCESS_KEY | S3 storage credentials |
Never commit .env files
Add .env to your .gitignore. Use your hosting provider's secrets management instead:
# .gitignore
.env
.env.local
.env.productionHTTPS 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:
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:
// 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.
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:
// 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:
// 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: trueon session cookies - [ ]
secure: truein 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
htmltemplate 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-Securityheader set - [ ]
Content-Security-Policyheader set
Infrastructure
- [ ] HTTPS everywhere
- [ ] Secrets in environment variables, not in code
- [ ]
.envin.gitignore - [ ] Dependencies kept up to date
Related
- Authentication Guide -- Setting up secure auth
- File Uploads Guide -- Securing file uploads
- Deployment Guide -- Production hardening