Skip to content

Sessions & Cookies

HTTP is stateless --- each request is independent, with no built-in memory of previous requests. Cookies and sessions are how web applications remember things about a user across multiple requests.

This guide covers both topics in depth. For a step-by-step introduction, see Part 6 of the tutorial.

What Are Cookies?

A cookie is a small piece of data that a server sends to a browser. The browser stores it and automatically includes it in every subsequent request to that server. Cookies are the foundation of:

  • Login/logout (remembering who you are)
  • Shopping carts (remembering what you added)
  • User preferences (language, theme)
  • Analytics and tracking

Cookies are sent via HTTP headers:

# Server sends a cookie
Set-Cookie: theme=dark; HttpOnly; Secure; SameSite=Lax; Max-Age=604800

# Browser includes the cookie in future requests
Cookie: theme=dark

Creating Cookies with createCookie()

Remix provides createCookie() to create cookie configurations:

ts
import { createCookie } from 'remix/cookie'

let sessionCookie = createCookie('__session', {
  httpOnly: true,
  secure: true,
  sameSite: 'Lax',
  secrets: ['my-secret-key-change-in-production'],
  maxAge: 60 * 60 * 24 * 7, // 1 week in seconds
})

The first argument is the cookie name --- the identifier that appears in the browser's developer tools and in the Cookie header.

OptionTypeDescription
httpOnlybooleanIf true, JavaScript in the browser cannot read the cookie. Protects against XSS attacks.
securebooleanIf true, the cookie is only sent over HTTPS connections.
sameSite'Strict' | 'Lax' | 'None'Controls when the cookie is sent with cross-site requests.
secretsstring[]Array of secrets used to sign the cookie value.
maxAgenumberHow long the cookie lasts, in seconds.
domainstringThe domain the cookie is valid for (e.g. .example.com).
pathstringThe URL path the cookie is valid for (default: '/').

httpOnly

When httpOnly is true, the cookie is invisible to JavaScript running in the browser (document.cookie cannot see it). This prevents Cross-Site Scripting (XSS) attacks from stealing session cookies.

Always set httpOnly for session cookies

Session cookies should always be httpOnly. There is no legitimate reason for client-side JavaScript to access the session cookie.

secure

When secure is true, the cookie is only sent over HTTPS connections. This prevents the cookie from being intercepted on unencrypted connections.

ts
secure: process.env.NODE_ENV === 'production',

Set it to false in development (where you use http://localhost) and true in production (where you use HTTPS).

sameSite

Controls when the cookie is sent with cross-site requests:

ValueBehavior
'Strict'Cookie is never sent with cross-site requests. Most secure, but breaks "Login with Google"-style flows.
'Lax'Cookie is sent with top-level navigations (clicking a link) but not with embedded requests (images, iframes). Good default.
'None'Cookie is always sent, even cross-site. Requires secure: true. Only use this if you need cross-site cookies (e.g. embedded widgets).

'Lax' is the recommended default. It protects against CSRF (Cross-Site Request Forgery) while still allowing normal navigation.

maxAge

How long the cookie persists, in seconds:

ts
maxAge: 60 * 60 * 24 * 7,  // 1 week
maxAge: 60 * 60 * 24 * 30, // 30 days
maxAge: 60 * 60,            // 1 hour

If you omit maxAge, the cookie becomes a session cookie that is deleted when the browser closes.

domain

By default, a cookie is only sent to the exact domain that set it. The domain option allows sharing across subdomains:

ts
domain: '.example.com',
// Cookie is valid for example.com, www.example.com, api.example.com, etc.

path

By default, the cookie is sent for all paths ('/'). You can restrict it:

ts
path: '/admin',
// Cookie is only sent for URLs starting with /admin

Cookie signing uses a secret key to create a cryptographic signature appended to the cookie value. This allows the server to detect if the cookie has been tampered with. It does not encrypt the value --- the data is still readable, but any modification invalidates the signature.

Why Sign Cookies?

Without signing, a user could modify their session cookie to pretend to be someone else. Signing prevents this:

  1. Server sets cookie: userId=42|signature=abc123
  2. User tries to change it to: userId=1|signature=abc123
  3. Server detects the signature does not match and rejects the cookie.

Secret Rotation

The secrets option accepts an array of strings. This supports secret rotation --- the process of changing your secret key without invalidating all existing sessions:

ts
let sessionCookie = createCookie('__session', {
  secrets: [
    process.env.SESSION_SECRET_NEW!,  // Current secret (used for signing)
    process.env.SESSION_SECRET_OLD!,  // Previous secret (accepted for verification)
  ],
})

The first secret in the array is used to sign new cookies. All secrets are tried when verifying incoming cookies. This means:

  1. Deploy with new secret added to the front of the array.
  2. Old cookies (signed with the old secret) continue to work.
  3. New cookies are signed with the new secret.
  4. After enough time has passed (longer than maxAge), remove the old secret.

Use strong secrets

Generate secrets with at least 32 random characters:

bash
node -e "console.log(crypto.randomUUID() + crypto.randomUUID())"

Never hardcode secrets in source code. Use environment variables.

What Are Sessions?

A session is server-side storage associated with a specific user, identified by a cookie. The cookie holds only a session ID (a short opaque string). The actual data (user ID, preferences, flash messages) lives on the server.

Browser                          Server
  |                                |
  |  Cookie: sid=abc123            |
  |------------------------------->|
  |                                |  Look up session "abc123"
  |                                |  session data: { userId: 42 }
  |  Response (personalized)       |
  |<-------------------------------|

Session Storage Backends

Session data can be stored in different places depending on your needs:

Stores session data directly in the cookie (encrypted). No server-side storage needed.

ts
import { createCookieSessionStorage } from 'remix/session/cookie-storage'

let sessionStorage = createCookieSessionStorage(sessionCookie)

Pros: No external dependencies, works everywhere. Cons: Limited to ~4KB of data, data is sent with every request.

Filesystem Storage

Stores session data as files on disk:

ts
import { createFsSessionStorage } from 'remix/session/fs-storage'

let sessionStorage = createFsSessionStorage('./sessions')

Pros: Simple, no extra software needed. Cons: Does not work with multiple servers (each has its own files). Not suitable for serverless or container deployments.

Memory Storage

Stores session data in-memory (useful for testing):

ts
import { createMemorySessionStorage } from 'remix/session/memory-storage'

let sessionStorage = createMemorySessionStorage()

Pros: Fast, no dependencies. Cons: Data is lost when the server restarts. Not suitable for production.

Redis Storage

Stores session data in Redis (an in-memory data store):

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

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

Pros: Fast, supports multiple servers, built-in expiration. Cons: Requires a Redis server.

Memcache Storage

Stores session data in Memcache:

ts
import { createMemcacheSessionStorage } from 'remix/session-storage-memcache'

let sessionStorage = createMemcacheSessionStorage({
  servers: [process.env.MEMCACHE_URL!],
})

Pros: Fast, supports multiple servers. Cons: Requires a Memcache server.

Choosing a Storage Backend

BackendDevelopmentSingle ServerMultiple Servers
CookieGoodGoodGood
FilesystemGoodGoodNo
MemoryTesting onlyNoNo
RedisOverkillGoodBest
MemcacheOverkillGoodGood

The Session Middleware

The session() middleware connects the cookie and storage to your router:

ts
import { session } from 'remix/session-middleware'
import { sessionCookie, sessionStorage } from './session.ts'

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

On every request, the middleware:

  1. Reads the session cookie from the request.
  2. Loads the session data from storage using the session ID in the cookie.
  3. Makes the session available via context.get(Session).
  4. After the handler runs, saves any changes back to storage.
  5. Updates the Set-Cookie header in the response.

The Session API

Access the session in any handler:

ts
import { Session } from 'remix/session'

handler({ get }) {
  let session = get(Session)
  // ...
}

session.get(key)

Read a value from the session:

ts
let userId = session.get('userId')   // number | undefined
let theme = session.get('theme')     // string | undefined

Returns undefined if the key does not exist.

session.set(key, value)

Write a value to the session:

ts
session.set('userId', 42)
session.set('theme', 'dark')
session.set('cart', [{ bookId: 1, quantity: 2 }])

Values can be any JSON-serializable data: strings, numbers, booleans, arrays, and objects.

session.has(key)

Check if a key exists in the session:

ts
if (session.has('userId')) {
  // User is logged in
}

session.unset(key)

Remove a single key from the session:

ts
session.unset('cart')

session.flash(key, value)

Set a value that is only available for one subsequent request, then automatically removed:

ts
// In the handler that processes an action
session.flash('message', 'Book created successfully!')
return createRedirectResponse('/books')
ts
// In the handler that renders the next page
let message = session.get('message')  // "Book created successfully!"
// On the next request, session.get('message') returns undefined

Flash values are perfect for success/error notifications after redirects.

session.destroy()

Destroy the entire session. All data is deleted and the cookie is cleared:

ts
session.destroy()
return createRedirectResponse('/login')

Use this for logout.

session.regenerateId()

Generate a new session ID while preserving the session data:

ts
session.regenerateId()

This is a security best practice after authentication state changes (login, privilege escalation). It prevents session fixation attacks where an attacker obtains a session ID and waits for the victim to log in with it.

Regenerate after login

Always call session.regenerateId() after a successful login:

ts
session.set('userId', user.id)
session.regenerateId()
return createRedirectResponse('/')

Flash Messages Pattern

Flash messages are one of the most common session patterns. Here is a complete implementation:

Setting Flash Messages

ts
// After creating a book
session.flash('flash', {
  type: 'success',
  message: 'Book created successfully!',
})
return createRedirectResponse('/books')

// After a validation error that requires a redirect
session.flash('flash', {
  type: 'error',
  message: 'Something went wrong. Please try again.',
})
return createRedirectResponse('/books/new')

Reading and Displaying Flash Messages

tsx
// In the handler
handler({ get }) {
  let session = get(Session)
  let flash = session.get('flash')
  let books = await db.findMany(booksTable)

  return render(<BookList books={books} flash={flash} />)
}

// In the component
function FlashMessage() {
  return ({ flash }: { flash?: { type: string; message: string } }) => {
    if (!flash) return null

    let bgColor = flash.type === 'success' ? '#d4edda' : '#f8d7da'
    let textColor = flash.type === 'success' ? '#155724' : '#721c24'

    return (
      <div mix={css({
        padding: '12px 16px',
        marginBottom: '16px',
        borderRadius: '4px',
        backgroundColor: bgColor,
        color: textColor,
      })}>
        {flash.message}
      </div>
    )
  }
}

Security Best Practices

Always Use httpOnly for Session Cookies

Prevents JavaScript from reading the cookie, protecting against XSS attacks.

Always Use secure in Production

Prevents the cookie from being sent over unencrypted HTTP connections.

Set sameSite to 'Lax' or 'Strict'

Prevents CSRF attacks by controlling when cookies are sent with cross-site requests.

Regenerate Session IDs After Login

Prevents session fixation attacks:

ts
session.set('userId', user.id)
session.regenerateId()

Use Strong, Random Secrets

Generate secrets with sufficient entropy (at least 32 characters). Never use predictable values like 'secret' or 'password123'.

Set Reasonable maxAge Values

Shorter session lifetimes reduce the window of opportunity for session hijacking. Balance security with user convenience:

  • High security (banking): 15-30 minutes
  • Normal applications: 1-7 days
  • "Remember me": 30-90 days

Store Minimal Data in Sessions

Only store what you need (typically just a user ID). Load full user data from the database on each request. This way, if a user's permissions change, the change takes effect immediately.

Use Server-Side Session Storage

Prefer Redis or filesystem storage over cookie storage for sensitive applications. Server-side storage lets you:

  • Invalidate sessions (e.g., "log out everywhere")
  • Store more data (cookies are limited to ~4KB)
  • Keep session data private (cookie storage, even when encrypted, reveals the data size)

Complete Session Setup Example

ts
import { createCookie } from 'remix/cookie'
import { createFsSessionStorage } from 'remix/session/fs-storage'

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
})

export let sessionStorage = createFsSessionStorage('./sessions')
ts
import { createRouter } from 'remix/fetch-router'
import { session } from 'remix/session-middleware'
import { sessionCookie, sessionStorage } from './session.ts'

let router = createRouter({
  middleware: [
    // ... other middleware ...
    session(sessionCookie, sessionStorage),
  ],
})
ts
import { Session } from 'remix/session'
import { createRedirectResponse } from 'remix/response/redirect'

// Login handler
async function login({ get }) {
  let data = get(FormData)
  let session = get(Session)

  // ... verify credentials ...

  session.set('userId', user.id)
  session.regenerateId()
  session.flash('flash', {
    type: 'success',
    message: `Welcome back, ${user.name}!`,
  })

  return createRedirectResponse('/')
}

// Logout handler
function logout({ get }) {
  let session = get(Session)
  session.destroy()
  return createRedirectResponse('/login')
}

Released under the MIT License.