Skip to content

6. Sessions & Authentication

Right now, anyone can visit /admin/books/new and add books to our store. We need a way to know who is making a request and restrict certain pages to authorized users. That requires two things: sessions to remember users across requests, and authentication to verify their identity.

What is a Session?

HTTP is stateless -- each request is independent. The server has no built-in way to know that two requests came from the same person. A session solves this by storing data about a user on the server and linking it to their browser with a small piece of data called a cookie.

Here is how it works:

  1. A user visits your site. The server creates a new session and sends back a session cookie -- a small string stored in the browser.
  2. On every subsequent request, the browser automatically sends that cookie back.
  3. The server reads the cookie, looks up the session data, and knows who the user is.

What is a cookie?

A cookie is a small piece of data that a server sends to a browser. The browser stores it and includes it in every future request to that server. Cookies are how the web remembers things about you -- your login status, your shopping cart, your language preference, and so on. They are set using the Set-Cookie HTTP header.

First, create a cookie that will hold the session identifier. Create app/session.ts:

ts
import { createCookie } from 'remix/cookie'

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

Let's go through each option:

  • '__session' -- The name of the cookie. This is what appears in the browser's developer tools.
  • httpOnly: true -- Prevents JavaScript running in the browser from reading the cookie. This is a security measure that protects against a class of attacks called XSS (Cross-Site Scripting).
  • secure: true -- The cookie is only sent over HTTPS connections. In development you may need to set this to false if you are not using HTTPS locally.
  • sameSite: 'Lax' -- Controls when the cookie is sent with cross-site requests. 'Lax' is a good default that prevents CSRF (Cross-Site Request Forgery) attacks while still allowing normal navigation.
  • secrets: ['my-secret-key'] -- An array of secret strings used to sign the cookie. Signing means the server can detect if someone tampered with the cookie value. Use a long, random string in production.
  • maxAge -- How long the cookie lasts, in seconds. 60 * 60 * 24 * 7 is one week.

Use real secrets in production

The string 'my-secret-key' is fine for development but dangerously weak for production. Use an environment variable with a long, randomly generated value:

ts
secrets: [process.env.SESSION_SECRET!],

We will cover environment variables in the deployment chapter.

Setting Up Session Storage

The cookie only holds the session ID -- a short string that identifies which session to look up. The actual session data (like the logged-in user's ID) is stored separately in a session storage backend.

For development, filesystem storage works well. Add this to app/session.ts:

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

export let sessionCookie = createCookie('__session', {
  httpOnly: true,
  secure: true,
  sameSite: 'Lax',
  secrets: ['my-secret-key'],
  maxAge: 60 * 60 * 24 * 7,
})

export let sessionStorage = createFsSessionStorage('./sessions')

createFsSessionStorage stores session data as files in the ./sessions directory. Each session gets its own file. The directory is created automatically if it does not exist.

Other storage backends

Filesystem storage is simple but does not scale to multiple servers (each server would have its own files). For production, use Redis or Memcache:

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

These store session data in a shared server that all your application instances can access.

Adding Session Middleware

Now connect the cookie and storage to your router so sessions are available in every request. Update app/server.ts:

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

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

The session() middleware does the following on every request:

  1. Reads the session cookie from the incoming request.
  2. Loads the session data from storage.
  3. Makes the session available via context.get(Session).
  4. After your handler runs, saves any changes back to storage and updates the cookie in the response.

Reading and Writing Session Data

Once the middleware is in place, you can read and write session data in any handler using the Session class:

ts
import { Session } from 'remix/session'

router.map(someRoute, ({ context }) => {
  let session = context.get(Session)

  // Write data to the session
  session.set('userId', 42)

  // Read data from the session
  let userId = session.get('userId') // 42

  // Check if a key exists
  session.has('userId') // true

  // Remove a key
  session.unset('userId')
})

The session middleware automatically saves changes at the end of the request, so you do not need to save manually.

Flash Messages

A flash message is a value that exists for only one request, then disappears. They are perfect for success and error notifications -- "Book created!" or "Invalid password."

ts
// In the handler that processes an action:
session.flash('message', 'Book created successfully!')

// Redirect to another page
return new Response(null, {
  status: 302,
  headers: { Location: '/books' },
})
ts
// In the handler that shows the next page:
let message = session.get('message') // 'Book created successfully!'
// The next time session.get('message') is called, it will return undefined

The flash value is available for one request after it was set, then it is automatically removed.

Setting Up the Users Table

Before building login, we need a place to store user accounts. Add a users table to app/db.ts:

ts
import { defineTable, column } from 'remix/data-table'

export let UsersTable = defineTable('users', {
  id: column.integer({ primaryKey: true }),
  email: column.text({ unique: true }),
  passwordHash: column.text(),
  name: column.text(),
  role: column.text({ default: 'user' }),
})

Never store plain-text passwords

We store a hash of the password, not the password itself. A hash is a one-way transformation -- you can turn a password into a hash, but you cannot turn a hash back into a password. This means that even if someone steals your database, they cannot read the passwords. We will use the verifyCredentials function from Remix's auth package to handle password hashing.

Run a migration or create the table so it exists in your database (refer to the database chapter for details).

Building the Login Form

Create app/routes/login.ts:

ts
import { form } from 'remix/fetch-router/routes'
import { html } from 'remix/html-template'

let loginRoutes = form('/login')

export { loginRoutes }

export function showLoginForm(error?: string) {
  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Log In - The Book Nook</title>
      </head>
      <body>
        <h1>Log In</h1>
        ${error ? `<p style="color: red">${error}</p>` : ''}
        <form method="post">
          <div>
            <label for="email">Email</label>
            <input type="email" id="email" name="email" required />
          </div>
          <div>
            <label for="password">Password</label>
            <input type="password" id="password" name="password" required />
          </div>
          <button type="submit">Log In</button>
        </form>
        <p>Don't have an account? <a href="/register">Register</a></p>
      </body>
    </html>
  `
}

Creating a Credentials Auth Provider

Remix's auth package provides a createCredentialsAuthProvider function that structures the login process into two steps: parse the submitted credentials and verify them against your database.

Create app/auth.ts:

ts
import {
  createCredentialsAuthProvider,
  verifyCredentials,
} from 'remix/auth'
import { Session } from 'remix/session'
import { selectFrom } from 'remix/data-table'
import { db, UsersTable } from './db.ts'

export let passwordAuth = createCredentialsAuthProvider({
  name: 'password',

  // Step 1: Extract the email and password from the form submission
  parse(context) {
    let data = context.get(FormData)
    return {
      email: data.get('email') as string,
      password: data.get('password') as string,
    }
  },

  // Step 2: Look up the user and verify the password
  async verify({ email, password }) {
    let user = await selectFrom(db, UsersTable, {
      where: { email },
    })

    if (!user) return null

    // Compare the submitted password with the stored hash
    // In a real app, use a proper password hashing library like bcrypt or argon2
    let passwordMatches = await comparePassword(password, user.passwordHash)
    if (!passwordMatches) return null

    // Return the user data (this becomes the "identity")
    return { id: user.id, email: user.email, name: user.name, role: user.role }
  },
})

// Helper to compare a plain password with a hash
// Replace this with bcrypt.compare() or argon2.verify() in production
async function comparePassword(password: string, hash: string): Promise<boolean> {
  let encoder = new TextEncoder()
  let data = encoder.encode(password)
  let digest = await crypto.subtle.digest('SHA-256', data)
  let computed = Array.from(new Uint8Array(digest))
    .map((b) => b.toString(16).padStart(2, '0'))
    .join('')
  return computed === hash
}

Use a proper password hashing library

The comparePassword function above uses SHA-256 for simplicity, but SHA-256 is not appropriate for password hashing in production. Use a library like bcrypt or argon2 that is specifically designed for password hashing. These are intentionally slow, which makes brute-force attacks impractical.

Handling the Login Submission

Wire up the login routes in app/server.ts:

ts
import { verifyCredentials } from 'remix/auth'
import { Session } from 'remix/session'
import { loginRoutes, showLoginForm } from './routes/login.ts'
import { passwordAuth } from './auth.ts'

// Show the login form
router.map(loginRoutes.index, () => {
  return showLoginForm()
})

// Handle login submission
router.map(loginRoutes.action, async ({ context }) => {
  let result = await verifyCredentials(passwordAuth, context)

  if (!result) {
    // Authentication failed
    return showLoginForm('Invalid email or password.')
  }

  // Authentication succeeded -- save user info to the session
  let session = context.get(Session)
  session.set('userId', result.id)
  session.flash('message', `Welcome back, ${result.name}!`)

  // Redirect to the home page
  return new Response(null, {
    status: 302,
    headers: { Location: '/' },
  })
})

Here is the flow:

  1. The user submits the login form.
  2. verifyCredentials calls the provider's parse function to extract the email and password from the form data.
  3. It then calls verify to check the credentials against the database.
  4. If verify returns null, authentication failed. We re-show the form with an error.
  5. If verify returns a user object, authentication succeeded. We store the user's ID in the session and redirect.

Building the Registration Form

Registration follows the same pattern as login, but it creates a user instead of verifying one. Create app/routes/register.ts:

ts
import { form } from 'remix/fetch-router/routes'
import { html } from 'remix/html-template'
import { object, string, parseSafe } from 'remix/data-schema'
import { email, minLength } from 'remix/data-schema/checks'

let registerRoutes = form('/register')

export { registerRoutes }

let RegisterSchema = object({
  name: string().pipe(minLength(1)),
  email: string().pipe(email()),
  password: string().pipe(minLength(8)),
})

export function showRegisterForm(
  errors?: Record<string, string>,
  values?: Record<string, string>,
) {
  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Register - The Book Nook</title>
      </head>
      <body>
        <h1>Create an Account</h1>
        <form method="post">
          <div>
            <label for="name">Name</label>
            <input type="text" id="name" name="name" value="${values?.name ?? ''}" />
            ${errors?.name ? `<p style="color: red">${errors.name}</p>` : ''}
          </div>
          <div>
            <label for="email">Email</label>
            <input type="email" id="email" name="email" value="${values?.email ?? ''}" />
            ${errors?.email ? `<p style="color: red">${errors.email}</p>` : ''}
          </div>
          <div>
            <label for="password">Password</label>
            <input type="password" id="password" name="password" />
            ${errors?.password ? `<p style="color: red">${errors.password}</p>` : ''}
          </div>
          <button type="submit">Register</button>
        </form>
        <p>Already have an account? <a href="/login">Log in</a></p>
      </body>
    </html>
  `
}

And the handler in app/server.ts:

ts
import { registerRoutes, showRegisterForm, RegisterSchema } from './routes/register.ts'
import { insertInto } from 'remix/data-table'

router.map(registerRoutes.index, () => {
  return showRegisterForm()
})

router.map(registerRoutes.action, async ({ context }) => {
  let data = context.get(FormData)
  let values = Object.fromEntries(data) as Record<string, string>

  let result = parseSafe(RegisterSchema, values)

  if (!result.success) {
    let errors: Record<string, string> = {}
    for (let issue of result.issues) {
      let key = issue.path?.[0]?.key
      if (typeof key === 'string') {
        errors[key] = issue.message
      }
    }
    return showRegisterForm(errors, values)
  }

  // Hash the password before storing it
  let passwordHash = await hashPassword(result.value.password)

  try {
    await insertInto(db, UsersTable, {
      email: result.value.email,
      passwordHash,
      name: result.value.name,
    })
  } catch (error) {
    // Handle duplicate email
    return showRegisterForm(
      { email: 'An account with this email already exists.' },
      values,
    )
  }

  // Log the user in immediately after registration
  let session = context.get(Session)
  // Look up the newly created user to get their ID
  let user = await selectFrom(db, UsersTable, {
    where: { email: result.value.email },
  })
  session.set('userId', user!.id)
  session.flash('message', 'Welcome to The Book Nook!')

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

Protecting Routes with Auth Middleware

Now we need to restrict the admin pages so only logged-in users can access them. Remix provides an auth middleware and a requireAuth middleware for this purpose.

Update app/auth.ts to set up session-based authentication:

ts
import { auth, requireAuth, Auth } from 'remix/auth-middleware'
import { createSessionAuthScheme } from 'remix/auth-middleware'
import { Session } from 'remix/session'
import { selectFrom } from 'remix/data-table'
import { db, UsersTable } from './db.ts'

// Define how to read auth state from the session
export let sessionAuthScheme = createSessionAuthScheme({
  name: 'session',

  // Read the user ID from the session
  read(session) {
    let userId = session.get('userId')
    return userId ?? null
  },

  // Verify the user ID is still valid (the user still exists)
  async verify(userId) {
    let user = await selectFrom(db, UsersTable, {
      where: { id: userId },
    })
    if (!user) return null
    return { id: user.id, email: user.email, name: user.name, role: user.role }
  },
})

// Create the auth middleware
export let authMiddleware = auth({
  schemes: [sessionAuthScheme],
})

// Create the requireAuth middleware for protected routes
export let requireAdmin = requireAuth({
  onFailure() {
    // Redirect unauthenticated users to the login page
    return new Response(null, {
      status: 302,
      headers: { Location: '/login' },
    })
  },
})

The auth system has three layers:

  1. createSessionAuthScheme -- Defines how to extract identity from the session. The read function pulls the user ID from the session. The verify function confirms the user still exists in the database.
  2. auth() -- Middleware that runs the auth scheme on every request and stores the result in context.get(Auth).
  3. requireAuth() -- Middleware that checks the auth state and rejects unauthenticated requests.

Now add the auth middleware to the router and protect the admin routes:

ts
import { authMiddleware, requireAdmin, Auth } from './auth.ts'

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

// Public routes (no auth required)
router.map(bookRoutes.list, showBookList)
router.map(bookRoutes.detail, showBookDetail)
router.map(loginRoutes.index, () => showLoginForm())
router.map(loginRoutes.action, handleLogin)

// Protected admin routes
router.map(newBookRoutes.index, { middleware: [requireAdmin] }, () => {
  return showNewBookForm()
})

router.map(newBookRoutes.action, { middleware: [requireAdmin] }, async ({ context }) => {
  // ... handle form submission (only authenticated users reach this code)
})

The requireAdmin middleware runs before the route handler. If the user is not logged in, they are redirected to /login and never reach the handler.

Checking Auth State in Handlers

You can also check auth state directly in any handler using context.get(Auth):

ts
import { Auth } from 'remix/auth-middleware'

router.map(homeRoute, ({ context }) => {
  let authState = context.get(Auth)

  if (authState.ok) {
    // User is logged in
    let user = authState.identity
    return html`<p>Welcome, ${user.name}! <a href="/logout">Log out</a></p>`
  }

  // User is not logged in
  return html`<p><a href="/login">Log in</a> or <a href="/register">Register</a></p>`
})

Building Logout

Logout destroys the session so the user is no longer recognized. Create app/routes/logout.ts:

ts
import { post } from 'remix/fetch-router/routes'

export let logoutRoute = post('/logout')

And the handler:

ts
import { logoutRoute } from './routes/logout.ts'

router.map(logoutRoute, ({ context }) => {
  let session = context.get(Session)
  session.destroy()

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

session.destroy() marks the session as destroyed. The session middleware will delete the session data from storage and clear the cookie in the response.

Add a logout button to your layout (it must be a form with method="post" for security):

html
<form method="post" action="/logout">
  <button type="submit">Log Out</button>
</form>

Why use POST for logout?

Logout should use POST, not GET, because it changes server state (destroys the session). If logout were a GET request, a malicious site could log users out by embedding <img src="https://yoursite.com/logout"> in their page. Using POST with a form prevents this.

Displaying Flash Messages

Let's put flash messages to use by showing them on any page. You can create a helper function that reads and renders flash messages:

ts
import { Session } from 'remix/session'

export function getFlashMessage(context: any): string {
  let session = context.get(Session)
  let message = session.get('message')
  if (!message) return ''
  return `<div style="background: #d4edda; padding: 12px; margin-bottom: 16px; border-radius: 4px">${message}</div>`
}

Then use it in your page handlers:

ts
router.map(bookRoutes.list, async ({ context }) => {
  let flash = getFlashMessage(context)
  let books = await selectFrom(db, BooksTable)

  return html`
    <!doctype html>
    <html lang="en">
      <head>
        <meta charset="utf-8" />
        <title>Books - The Book Nook</title>
      </head>
      <body>
        ${flash}
        <h1>Books</h1>
        <!-- ... book list ... -->
      </body>
    </html>
  `
})

After creating a book, the flash message "Book created successfully!" will appear once at the top of the book list, then disappear on the next page load.

Summary

In this chapter you learned:

  • Sessions store per-user data on the server, linked to the browser via a cookie
  • createCookie configures a signed, secure session cookie
  • createFsSessionStorage stores session data on the filesystem (use Redis or Memcache in production)
  • The session() middleware makes context.get(Session) available in all handlers
  • session.set(), session.get(), and session.flash() manage session data
  • createCredentialsAuthProvider structures the parse-and-verify login flow
  • createSessionAuthScheme reads identity from session data
  • auth() middleware loads auth state, requireAuth() protects routes
  • session.destroy() logs the user out
  • Flash messages provide one-time notifications across redirects

Next, you will learn how to handle file uploads so users can add cover images to books.

Released under the MIT License.