Tutorial: Working with Cookies
In this tutorial, you will create cookies for different purposes, sign and verify cookie values, implement a "remember me" login option, and perform a secret rotation without breaking existing user sessions.
Prerequisites
- A Remix V3 project with a router set up (see the fetch-router tutorial)
What You Will Build
- A signed session cookie
- A theme preference cookie readable by client-side JavaScript
- A "remember me" login flow with different cookie lifetimes
- A secret rotation procedure
Step 1: Create a Signed Session Cookie
The most common cookie is a session cookie. It should be signed (to detect tampering), HTTP-only (to prevent XSS attacks from stealing it), and secure (HTTPS only in production).
// app/cookies.ts
import { createCookie } from 'remix/cookie'
export let sessionCookie = createCookie('__session', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
secrets: [process.env.SESSION_SECRET!],
maxAge: 60 * 60 * 24 * 7, // 1 week
})Use it to read and write cookie values:
// Reading: parse the Cookie header from the request
let cookieHeader = request.headers.get('Cookie') ?? ''
let sessionId = await sessionCookie.parse(cookieHeader)
// "abc123" or null (if missing or tampered)
// Writing: create a Set-Cookie header for the response
let setCookie = await sessionCookie.serialize('abc123')
return new Response('OK', {
headers: { 'Set-Cookie': setCookie },
})Step 2: Create a Theme Cookie
For a theme preference that client-side JavaScript needs to read (e.g. to set data-theme before hydration), set httpOnly: false:
// app/cookies.ts (continued)
export let themeCookie = createCookie('theme', {
httpOnly: false, // Client-side JS can read this
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
maxAge: 60 * 60 * 24 * 365, // 1 year
})No signing is needed here -- the theme value is not security-sensitive. If a user manually changes it to 'dark' or 'light', that is fine.
Set the theme in a handler:
router.post(setThemeRoute, async ({ context }) => {
let formData = await context.request.formData()
let theme = formData.get('theme') as string
let setCookie = await themeCookie.serialize(theme)
return new Response(null, {
status: 302,
headers: {
Location: '/',
'Set-Cookie': setCookie,
},
})
})Read it in another handler:
router.get(homeRoute, async ({ context }) => {
let theme = await themeCookie.parse(
context.request.headers.get('Cookie') ?? '',
)
let currentTheme = theme ?? 'light'
return new Response(
`<html data-theme="${currentTheme}">...</html>`,
{ headers: { 'Content-Type': 'text/html' } },
)
})Step 3: Store Structured Data in a Cookie
Cookie values can be any JSON-serializable data, not just strings:
export let consentCookie = createCookie('cookie_consent', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Strict',
maxAge: 60 * 60 * 24 * 365,
})
// Serialize an object
let setCookie = await consentCookie.serialize({
analytics: true,
marketing: false,
timestamp: Date.now(),
})
// Parse it back
let consent = await consentCookie.parse(cookieHeader)
// { analytics: true, marketing: false, timestamp: 1712345678000 }Step 4: Implement "Remember Me"
A common login pattern is offering a "Remember me" checkbox. When checked, the session lasts for weeks. When unchecked, the session expires when the browser closes.
The trick is to create the cookie with a long maxAge, but override it at serialization time:
// app/cookies.ts
export let sessionCookie = createCookie('__session', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
secrets: [process.env.SESSION_SECRET!],
// No maxAge here -- we set it dynamically
})In the login handler, choose the lifetime based on the checkbox:
router.post(loginRoute, async ({ context }) => {
let formData = await context.request.formData()
let email = formData.get('email') as string
let password = formData.get('password') as string
let rememberMe = formData.get('remember') === 'on'
// ... verify credentials ...
let session = context.get(Session)
session.set('userId', user.id)
session.regenerateId()
// Choose cookie lifetime based on "Remember me"
if (rememberMe) {
// Persistent cookie -- lasts 30 days
let setCookie = await sessionCookie.serialize(session.id, {
maxAge: 60 * 60 * 24 * 30,
})
return new Response(null, {
status: 302,
headers: {
Location: '/',
'Set-Cookie': setCookie,
},
})
}
// Session cookie -- no maxAge means it expires when the browser closes
let setCookie = await sessionCookie.serialize(session.id)
return new Response(null, {
status: 302,
headers: {
Location: '/',
'Set-Cookie': setCookie,
},
})
})The login form:
<form method="POST" action="/login">
<label>Email <input type="email" name="email" required /></label>
<label>Password <input type="password" name="password" required /></label>
<label><input type="checkbox" name="remember" /> Remember me</label>
<button type="submit">Log In</button>
</form>Step 5: Clear a Cookie
To delete a cookie, serialize it with an empty value and maxAge: 0:
let setCookie = await sessionCookie.serialize('', { maxAge: 0 })
return new Response(null, {
status: 302,
headers: {
Location: '/login',
'Set-Cookie': setCookie,
},
})This tells the browser to remove the cookie immediately.
Step 6: Rotate Secrets
Over time, you should rotate your signing secrets. Here is the procedure:
Step 6a: Generate a new secret.
node -e "console.log(crypto.randomUUID() + crypto.randomUUID())"Step 6b: Add the new secret as SESSION_SECRET_NEW and rename the old one to SESSION_SECRET_OLD.
# .env
SESSION_SECRET_NEW=new-random-secret-here
SESSION_SECRET_OLD=old-random-secret-hereStep 6c: Update the cookie configuration to use both secrets.
let sessionCookie = createCookie('__session', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
secrets: [
process.env.SESSION_SECRET_NEW!, // Used for signing new cookies
process.env.SESSION_SECRET_OLD!, // Still accepted for verification
],
maxAge: 60 * 60 * 24 * 7,
})Step 6d: Deploy. New cookies are signed with the new secret. Existing cookies signed with the old secret still verify successfully.
Step 6e: Wait for maxAge to elapse. After one week (or whatever your maxAge is), all cookies signed with the old secret have expired.
Step 6f: Remove the old secret.
secrets: [process.env.SESSION_SECRET_NEW!]Never use weak secrets
Always generate secrets with at least 32 random characters. Never use words like 'secret' or 'password'. Never commit secrets to source code -- use environment variables.
Summary
| Pattern | Key Options |
|---|---|
| Session cookie | httpOnly: true, secure: true, secrets, maxAge |
| Theme/preference cookie | httpOnly: false, long maxAge |
| Remember me | Dynamic maxAge at serialization time |
| Clear a cookie | serialize('', { maxAge: 0 }) |
| Secret rotation | New secret at front of secrets array, old secret kept for verification |
Next Steps
- Session Overview -- Use cookies with session storage.
- API Reference -- Full documentation of
createCookie, options, and methods.