references/auth-and-sessions.md
references/auth-and-sessions.mdBrowse 12 files
11,527 bytes
Token encoding: o200k_base
Snapshot 8beb6cd
Authentication and Sessions
What This Covers
How to remember things about a browser between requests and how to identify a user. Read this when the task involves:
- Storing per-browser state across requests (login, cart, "I have submitted this form")
- Adding a credentials login flow or an OAuth provider
- Protecting routes with
requireAuth()or stacking authorization checks - Reading or writing
Session,Auth, or other identity-related context values - Logging in, logging out, or rotating session IDs
For raw cookies that are not session-backed (theme, locale, dismissed-banner),
see createCookie in this file plus the broader Package Map in SKILL.md.
Sessions vs Plain Cookies
Reach for remix/session when state is sensitive, must be tamper-resistant, or
represents the identity of a request: who is logged in, which form a browser
already submitted, what items are in a cart. Sessions sign or encrypt their
backing cookie with a server-held secret and give you a typed Session object
you can get, set, flash, unset, and regenerateId.
Reach for remix/cookie directly when the browser is allowed to carry the value
and the server does not need session semantics. This often means preferences
(theme, locale, dismissed banner), but a signed cookie can also be fine for
small low-risk values where you truly only need one cookie-shaped fact and do
not need Session helpers.
If a malicious user editing the value would be a bug, or if the value needs server-managed lifecycle, reach for a session.
Quick chooser
| Need | Best fit | Why |
|---|---|---|
| Theme, locale, dismissed banner | remix/cookie | Browser-controlled preference |
| Small signed hint with minimal lifecycle | remix/cookie | One value, no Session helpers needed |
| "This browser already submitted", cart, flash messages, login state | remix/session | Tamper-sensitive, server-managed per-browser state |
| "One real person only", ownership, durable identity | account/auth | Cookies or sessions alone do not prove personhood |
Session Setup
Create a session cookie
import { createCookie } from 'remix/cookie'
let sessionSecret = process.env.SESSION_SECRET
if (!sessionSecret && process.env.NODE_ENV !== 'test') {
throw new Error('SESSION_SECRET is required')
}
export let sessionCookie = createCookie('session', {
secrets: [sessionSecret ?? 'test-only-secret'],
httpOnly: true,
sameSite: 'Lax',
secure: process.env.NODE_ENV === 'production',
maxAge: 2592000, // 30 days
path: '/',
})
The cookie should always be httpOnly, default to sameSite: 'Lax', and be
secure in production. Demo defaults like 's3cr3t' are fine in tests but
should never reach production — fail fast when the secret is missing.
Create session storage
// Filesystem storage
import { createFsSessionStorage } from 'remix/session-storage/fs'
export let sessionStorage = createFsSessionStorage('./tmp/sessions')
// Memory storage (for tests)
import { createMemorySessionStorage } from 'remix/session-storage/memory'
export let sessionStorage = createMemorySessionStorage()
Add session middleware
import { session } from 'remix/middleware/session'
let router = createRouter({
middleware: [
session(sessionCookie, sessionStorage),
// ... other middleware
],
})
Using sessions in handlers
import { Session } from 'remix/session'
async function handler({ get }) {
let session = get(Session)
// Read
let userId = session.get('userId')
// Write
session.set('userId', 42)
// Flash (read once, then cleared)
session.flash('message', 'Settings saved!')
let message = session.get('message') // returns and clears
// Remove a key
session.unset('userId')
// Regenerate session ID (after login/logout)
session.regenerateId(true)
}
Sessions for non-auth state
Sessions are not just for login. They are the right place to store any tamper-sensitive per-browser fact: which form a browser already submitted, how many free actions are left in a trial, which feature flags a tester opted into, what items are in a cart.
async function submit({ get }) {
let session = get(Session)
if (session.get('hasSubmitted')) {
return render(<AlreadySubmittedPage />, { status: 409 })
}
let parsed = s.parseSafe(submitSchema, get(FormData))
if (!parsed.success) {
return render(<SubmitPage errors={parsed.issues} />, { status: 400 })
}
await saveSubmission(parsed.value)
session.set('hasSubmitted', true)
session.flash('message', 'Thanks for submitting!')
return redirect(routes.thanks.href())
}
Notice that there is no manual Set-Cookie plumbing in the action — the session
middleware handles that, and the handler returns an ordinary Response.
Per-browser state enforced this way is still bypassable by clearing cookies; if
the guarantee needs to survive that, you also need an account (see auth
providers below).
Auth Middleware
Basic setup
import { auth, createSessionAuthScheme } from 'remix/middleware/auth'
import { Session } from 'remix/session'
import { Database } from 'remix/data-table'
export function loadAuth() {
return auth({
schemes: [
createSessionAuthScheme({
read(session) {
let data = session.get('auth')
return data ?? null
},
async verify(value, context) {
let db = context.get(Database)
return (await db.find(users, value.userId)) ?? null
},
invalidate(session) {
session.unset('auth')
},
}),
],
})
}
Reading auth state
import { Auth } from 'remix/middleware/auth'
function handler({ get }) {
let auth = get(Auth)
if (auth.ok) {
// User is authenticated
let user = auth.identity
}
}
Credentials Auth
Define a credentials provider
import {
createCredentialsAuthProvider,
verifyCredentials,
completeAuth,
} from 'remix/auth'
import * as s from 'remix/data-schema'
import * as f from 'remix/data-schema/form-data'
let loginSchema = f.object({
email: f.field(s.defaulted(s.string(), '')),
password: f.field(s.defaulted(s.string(), '')),
})
export let passwordProvider = createCredentialsAuthProvider({
parse(context) {
let formData = context.get(FormData)
return s.parse(loginSchema, formData)
},
async verify({ email, password }, context) {
let db = context.get(Database)
let user = await db.findOne(users, { where: { email } })
if (!user || !(await verifyPassword(password, user.password_hash))) {
return null
}
return user
},
})
Login action
import { verifyCredentials, completeAuth } from 'remix/auth'
import { redirect } from 'remix/response/redirect'
async action(context) {
let user = await verifyCredentials(passwordProvider, context)
if (user == null) {
let session = context.get(Session)
session.flash('error', 'Invalid email or password.')
return redirect(routes.auth.login.href())
}
let session = completeAuth(context)
session.set('auth', { userId: user.id })
return redirect(routes.home.href())
},
Logout action
import { Session } from 'remix/session'
import { redirect } from 'remix/response/redirect'
function logout(context) {
let session = context.get(Session)
session.unset('auth')
session.regenerateId(true)
return redirect(routes.home.href())
}
OAuth / External Auth
Create providers
import {
createOAuthProvider,
createGoogleAuthProvider,
createGitHubAuthProvider,
startExternalAuth,
finishExternalAuth,
completeAuth,
refreshExternalAuth,
} from 'remix/auth'
let googleProvider = createGoogleAuthProvider({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
redirectUri: new URL(routes.auth.google.callback.href(), origin),
})
let githubProvider = createGitHubAuthProvider({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
redirectUri: new URL(routes.auth.github.callback.href(), origin),
})
There is no built-in Atmosphere/atproto Remix auth provider. Use
createOAuthProvider() in a separate package if you need that protocol.
OAuth controller
export default {
actions: {
// GET /auth/google — redirect to Google
async index(context) {
return await startExternalAuth(googleProvider, context, {
returnTo: context.url.searchParams.get('returnTo'),
})
},
// GET /auth/google/callback — handle redirect back
async callback(context) {
let { result, returnTo } = await finishExternalAuth(
googleProvider,
context,
)
let db = context.get(Database)
let { user, authAccount } = await resolveExternalAuth(db, result)
let session = completeAuth(context)
session.set('auth', {
userId: user.id,
loginMethod: result.provider,
authAccountId: authAccount.id,
})
return redirect(returnTo ?? routes.account.href())
},
},
} satisfies Controller<typeof routes.auth.google>
Refresh stored provider tokens
Use refreshExternalAuth(provider, tokens) when an app has stored OAuth/OIDC
tokens and needs a fresh access token from a refresh token. Built-in OIDC
providers and X support refresh-token exchange. Custom createOAuthProvider()
integrations can too. If the provider does not rotate the refresh token, the
refreshed bundle preserves the current one.
async function refreshGoogleTokens({ get }) {
let db = get(Database)
let account = await db.findOne(authAccounts, {
where: { provider: 'google' },
})
if (!account) return null
let refreshed = await refreshExternalAuth(googleProvider, account.tokens)
await db.update(authAccounts, account.id, { tokens: refreshed.tokens })
return refreshed.tokens
}
Protecting Routes
Controller-level protection
Apply requireAuth() to an entire controller subtree:
import { requireAuth } from 'remix/middleware/auth'
export default {
middleware: [requireAuth()],
actions: {
index() {
/* guaranteed authenticated */
},
settings: settingsController,
},
} satisfies Controller<typeof routes.account>
Stacking middleware
Combine auth checks with role checks:
export default {
middleware: [requireAuth(), requireAdmin()],
actions: {
index() {
/* requires auth + admin */
},
},
} satisfies Controller<typeof routes.admin>
Action-level protection
Apply middleware to a single route:
import { Auth, requireAuth } from 'remix/middleware/auth'
router.get(routes.account, {
middleware: [requireAuth()],
handler(context) {
let auth = context.get(Auth)
return render(<AccountPage identity={auth.identity} />)
},
})
Redirect on auth failure
import { requireAuth } from 'remix/middleware/auth'
import { redirect } from 'remix/response/redirect'
export function requireAuthRedirect() {
return requireAuth({
onFailure(context) {
let returnTo = encodeURIComponent(context.url.pathname)
return redirect(routes.auth.login.href() + `?returnTo=${returnTo}`, 303)
},
})
}