Set up session storage
Configure session storage to maintain user authentication state:For production with multiple servers, use a persistent session store:
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')
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,
},
})
Add session middleware
Enable session management in your router:The session middleware makes the session available via
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),
],
})
get(Session) in all route handlers.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.
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>
)
}
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>
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()
}
}
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} />)
})
Add role-based authorization
Implement permission checks for different user roles:Create authorization middleware:Protect admin routes:
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
}
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()
}
}
router.get('/admin/users', requireRole('admin'), () => {
return render(<AdminUsersPage />)
})
router.get('/admin/settings', requireRole('admin', 'moderator'), () => {
return render(<AdminSettingsPage />)
})
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: truein production (requires HTTPS) - Use
httpOnly: trueto 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
}