Skip to main content
1

Set up session storage

Configure session storage to maintain user authentication state:
app/session.ts
import { createCookieSessionStorage } from 'remix/session/cookie-storage'

export let sessionStorage = createCookieSessionStorage({
  cookie: {
    name: 'session',
    secrets: [process.env.SESSION_SECRET || 'dev-secret-change-in-production'],
    secure: process.env.NODE_ENV === 'production',
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7, // 7 days
    path: '/',
  },
})

// Type-safe session key
export let Session = Symbol('Session')
For production with multiple servers, use a persistent session store:
app/session.ts
import { createRedisSessionStorage } from 'remix/session-storage-redis'
import { createClient } from 'redis'

let redisClient = createClient({
  url: process.env.REDIS_URL,
})

await redisClient.connect()

export let sessionStorage = createRedisSessionStorage({
  client: redisClient,
  cookie: {
    name: 'session',
    secrets: [process.env.SESSION_SECRET!],
    secure: true,
    httpOnly: true,
    sameSite: 'lax',
    maxAge: 60 * 60 * 24 * 7,
  },
})
2

Add session middleware

Enable session management in your router:
app/router.ts
import { createRouter } from 'remix/fetch-router'
import { session } from 'remix/session-middleware'
import { sessionStorage, Session } from './session.ts'

export let router = createRouter({
  middleware: [
    session(Session, sessionStorage),
  ],
})
The session middleware makes the session available via get(Session) in all route handlers.
3

Create a user database

Set up a simple user storage system. For this example, we’ll use an in-memory store:
app/models/user.ts
export interface User {
  id: number
  email: string
  password: string // In production, store hashed passwords!
  name: string
  createdAt: Date
}

// Sample users (in production, use a real database)
let users: User[] = [
  {
    id: 1,
    email: '[email protected]',
    password: 'password123', // Never store plain passwords!
    name: 'Alice',
    createdAt: new Date(),
  },
]

export async function findUserByEmail(email: string): Promise<User | undefined> {
  return users.find(u => u.email.toLowerCase() === email.toLowerCase())
}

export async function findUserById(id: number): Promise<User | undefined> {
  return users.find(u => u.id === id)
}

export async function createUser(data: Omit<User, 'id' | 'createdAt'>): Promise<User> {
  let user: User = {
    ...data,
    id: users.length + 1,
    createdAt: new Date(),
  }
  users.push(user)
  return user
}
In production, always hash passwords using bcrypt or argon2, and use a real database.
4

Create login and registration pages

Build forms for user authentication:
app/pages/login.tsx
import { css } from 'remix/component'
import { Document } from '../layout.tsx'

interface LoginPageProps {
  error?: string
  returnTo?: string
}

export function LoginPage({ error, returnTo }: LoginPageProps) {
  return (
    <Document title="Login">
      <div
        mix={[
          css({
            maxWidth: '400px',
            margin: '2rem auto',
            padding: '2rem',
            border: '1px solid #ddd',
            borderRadius: '8px',
          }),
        ]}
      >
        <h1>Login</h1>

        {error && (
          <div
            mix={[
              css({
                padding: '1rem',
                background: '#fee',
                border: '1px solid #fcc',
                borderRadius: '4px',
                marginBottom: '1rem',
              }),
            ]}
          >
            {error}
          </div>
        )}

        <form
          method="POST"
          action={`/auth/login${returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''}`}
        >
          <div mix={[css({ marginBottom: '1rem' })]}>
            <label
              for="email"
              mix={[css({ display: 'block', marginBottom: '0.5rem' })]}
            >
              Email
            </label>
            <input
              type="email"
              id="email"
              name="email"
              required
              autocomplete="email"
              mix={[
                css({
                  width: '100%',
                  padding: '0.5rem',
                  border: '1px solid #ddd',
                  borderRadius: '4px',
                }),
              ]}
            />
          </div>

          <div mix={[css({ marginBottom: '1rem' })]}>
            <label
              for="password"
              mix={[css({ display: 'block', marginBottom: '0.5rem' })]}
            >
              Password
            </label>
            <input
              type="password"
              id="password"
              name="password"
              required
              autocomplete="current-password"
              mix={[
                css({
                  width: '100%',
                  padding: '0.5rem',
                  border: '1px solid #ddd',
                  borderRadius: '4px',
                }),
              ]}
            />
          </div>

          <button
            type="submit"
            mix={[
              css({
                width: '100%',
                padding: '0.75rem',
                background: '#0070f3',
                color: 'white',
                border: 'none',
                borderRadius: '4px',
                cursor: 'pointer',
                fontSize: '1rem',
              }),
            ]}
          >
            Login
          </button>
        </form>

        <p mix={[css({ marginTop: '1rem', textAlign: 'center' })]}>              
          Don't have an account? <a href="/auth/register">Register</a>
        </p>
      </div>
    </Document>
  )
}
5

Implement authentication routes

Create route handlers for login and logout:
app/auth.ts
import type { Controller } from 'remix/fetch-router'
import { redirect } from 'remix/response/redirect'
import { formData } from 'remix/form-data-middleware'
import { routes } from 'remix/fetch-router/routes'
import { render } from './utils/render.ts'
import { LoginPage } from './pages/login.tsx'
import { Session } from './session.ts'
import { findUserByEmail, createUser } from './models/user.ts'

export let authRoutes = routes({
  login: {
    index: 'GET /auth/login',
    submit: 'POST /auth/login',
  },
  logout: 'POST /auth/logout',
  register: {
    index: 'GET /auth/register',
    submit: 'POST /auth/register',
  },
})

export default {
  middleware: [formData()],
  actions: {
    login: {
      actions: {
        // Show login form
        index({ get, url }) {
          let session = get(Session)
          let error = session.get('error')
          let returnTo = url.searchParams.get('returnTo') || undefined

          return render(<LoginPage error={error} returnTo={returnTo} />)
        },

        // Process login
        async submit({ get, url }) {
          let session = get(Session)
          let form = get(FormData)
          
          let email = form.get('email')?.toString() ?? ''
          let password = form.get('password')?.toString() ?? ''
          let returnTo = url.searchParams.get('returnTo') || '/dashboard'

          // Find user
          let user = await findUserByEmail(email)
          
          // Verify password (in production, use bcrypt.compare)
          if (!user || user.password !== password) {
            session.flash('error', 'Invalid email or password')
            return redirect(
              `/auth/login${returnTo ? `?returnTo=${encodeURIComponent(returnTo)}` : ''}`
            )
          }

          // Regenerate session ID to prevent session fixation
          session.regenerateId(true)
          
          // Store user ID in session
          session.set('userId', user.id)

          return redirect(returnTo)
        },
      },
    },

    // Logout
    logout({ get }) {
      let session = get(Session)
      session.destroy()
      return redirect('/auth/login')
    },

    register: {
      actions: {
        // Show registration form
        index() {
          return render(<RegisterPage />)
        },

        // Process registration
        async submit({ get }) {
          let session = get(Session)
          let form = get(FormData)
          
          let email = form.get('email')?.toString() ?? ''
          let password = form.get('password')?.toString() ?? ''
          let name = form.get('name')?.toString() ?? ''

          // Check if user exists
          if (await findUserByEmail(email)) {
            session.flash('error', 'Email already in use')
            return redirect('/auth/register')
          }

          // Create user (in production, hash password first)
          let user = await createUser({ email, password, name })

          // Log them in
          session.set('userId', user.id)

          return redirect('/dashboard')
        },
      },
    },
  },
} satisfies Controller<typeof authRoutes>
6

Create authentication middleware

Build middleware to protect routes that require authentication:
app/middleware/auth.ts
import type { Middleware } from 'remix/fetch-router'
import { redirect } from 'remix/response/redirect'
import { Session } from '../session.ts'
import { findUserById, type User } from '../models/user.ts'

// Context key for current user
export let CurrentUser = Symbol('CurrentUser')

/**
 * Middleware that loads the current user from the session.
 * Sets CurrentUser in context if logged in.
 */
export function loadUser(): Middleware {
  return async ({ get, set }, next) => {
    let session = get(Session)
    let userId = session.get('userId')

    if (userId) {
      let user = await findUserById(userId)
      if (user) {
        set(CurrentUser, user)
      }
    }

    return next()
  }
}

/**
 * Middleware that requires authentication.
 * Redirects to login if not authenticated.
 */
export function requireAuth(): Middleware {
  return async ({ get, url }, next) => {
    let user = get(CurrentUser) as User | undefined

    if (!user) {
      let returnTo = url.pathname + url.search
      return redirect(`/auth/login?returnTo=${encodeURIComponent(returnTo)}`)
    }

    return next()
  }
}

/**
 * Middleware that requires guest (not authenticated).
 * Redirects to dashboard if already logged in.
 */
export function requireGuest(): Middleware {
  return async ({ get }, next) => {
    let user = get(CurrentUser) as User | undefined

    if (user) {
      return redirect('/dashboard')
    }

    return next()
  }
}
7

Protect routes

Apply authentication middleware to protected routes:
app/router.ts
import { createRouter } from 'remix/fetch-router'
import { session } from 'remix/session-middleware'
import { routes } from 'remix/fetch-router/routes'
import { sessionStorage, Session } from './session.ts'
import { loadUser, requireAuth } from './middleware/auth.ts'
import authController, { authRoutes } from './auth.ts'
import dashboardController from './dashboard.ts'

export let appRoutes = routes({
  home: 'GET /',
  auth: authRoutes,
  dashboard: 'GET /dashboard',
  profile: 'GET /profile',
})

export let router = createRouter({
  middleware: [
    session(Session, sessionStorage),
    loadUser(), // Load user for all routes
  ],
})

// Public routes
router.get(appRoutes.home, () => {
  return render(<HomePage />)
})

// Auth routes
router.map(appRoutes.auth, authController)

// Protected routes
router.get(appRoutes.dashboard, requireAuth(), ({ get }) => {
  let user = get(CurrentUser)
  return render(<DashboardPage user={user} />)
})

router.get(appRoutes.profile, requireAuth(), ({ get }) => {
  let user = get(CurrentUser)
  return render(<ProfilePage user={user} />)
})
8

Add role-based authorization

Implement permission checks for different user roles:
app/models/user.ts
export type Role = 'user' | 'admin' | 'moderator'

export interface User {
  id: number
  email: string
  password: string
  name: string
  role: Role
  createdAt: Date
}
Create authorization middleware:
app/middleware/auth.ts
import { type Role } from '../models/user.ts'

/**
 * Middleware that requires specific roles.
 */
export function requireRole(...roles: Role[]): Middleware {
  return async ({ get }, next) => {
    let user = get(CurrentUser) as User | undefined

    if (!user) {
      return redirect('/auth/login')
    }

    if (!roles.includes(user.role)) {
      return new Response('Forbidden', { status: 403 })
    }

    return next()
  }
}
Protect admin routes:
router.get('/admin/users', requireRole('admin'), () => {
  return render(<AdminUsersPage />)
})

router.get('/admin/settings', requireRole('admin', 'moderator'), () => {
  return render(<AdminSettingsPage />)
})
9

Display user info in layout

Show logged-in user information:
app/layout.tsx
import { css } from 'remix/component'
import type { User } from './models/user.ts'

interface DocumentProps {
  user?: User
  children: RemixNode
}

export function Document({ user, children }: DocumentProps) {
  return (
    <html lang="en">
      <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>My App</title>
      </head>
      <body>
        <nav
          mix={[
            css({
              padding: '1rem',
              background: '#f0f0f0',
              display: 'flex',
              justifyContent: 'space-between',
              alignItems: 'center',
            }),
          ]}
        >
          <div>
            <a href="/">Home</a>
            {user && (
              <>
                {' | '}
                <a href="/dashboard">Dashboard</a>
                {' | '}
                <a href="/profile">Profile</a>
              </>
            )}
          </div>

          <div>
            {user ? (
              <>
                <span mix={[css({ marginRight: '1rem' })]}>                      
                  Hello, {user.name}!
                </span>
                <form method="POST" action="/auth/logout" style="display: inline;">
                  <button
                    type="submit"
                    mix={[
                      css({
                        padding: '0.5rem 1rem',
                        background: '#dc3545',
                        color: 'white',
                        border: 'none',
                        borderRadius: '4px',
                        cursor: 'pointer',
                      }),
                    ]}
                  >
                    Logout
                  </button>
                </form>
              </>
            ) : (
              <a href="/auth/login">Login</a>
            )}
          </div>
        </nav>

        <main mix={[css({ padding: '2rem' })]}>
          {children}
        </main>
      </body>
    </html>
  )
}

Security Best Practices

Always hash passwords

Use bcrypt or argon2 for password hashing:
import bcrypt from 'bcrypt'

export async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, 12)
}

export async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash)
}

Use secure session configuration

  • Set secure: true in production (requires HTTPS)
  • Use httpOnly: true to prevent XSS attacks
  • Set sameSite: 'lax' or 'strict' to prevent CSRF
  • Use long, random secrets

Regenerate session IDs

Always regenerate session IDs after login to prevent session fixation:
session.regenerateId(true)

Implement rate limiting

Limit login attempts to prevent brute force attacks:
let loginAttempts = new Map<string, number>()

export function rateLimitLogin(email: string): boolean {
  let attempts = loginAttempts.get(email) || 0
  
  if (attempts >= 5) {
    return false // Too many attempts
  }
  
  loginAttempts.set(email, attempts + 1)
  setTimeout(() => loginAttempts.delete(email), 15 * 60 * 1000) // Reset after 15 min
  
  return true
}

Use HTTPS in production

Always serve your application over HTTPS to protect session cookies and credentials.

Build docs developers (and LLMs) love