Files
dotfiles/dot_agents/skills/remix/references/auth-and-sessions.md
T
Prad Nukala 42d17e825e chs: re-add drifted files
dot_agents/dot_skill-lock.json
2026-08-14 13:53:55 -04:00

13 KiB

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

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 { databaseContext } from '~/middleware/database.ts'

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(databaseContext)
          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(databaseContext)
    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 {
  createAtmosphereAuthProvider,
  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),
})

let atmosphereSessionSecret = process.env.ATMOSPHERE_SESSION_SECRET
if (!atmosphereSessionSecret && process.env.NODE_ENV !== 'test') {
  throw new Error('ATMOSPHERE_SESSION_SECRET is required')
}

let atmosphereProvider = createAtmosphereAuthProvider({
  clientId: 'https://app.example.com/oauth/client-metadata.json',
  redirectUri: new URL(routes.auth.atmosphere.callback.href(), origin),
  sessionSecret: atmosphereSessionSecret ?? 'test-only-secret',
})

For Atmosphere-compatible atproto OAuth, create the provider once, call atmosphereProvider.prepare(handleOrDid) before startExternalAuth(...), then pass the same module-scope provider to finishExternalAuth(...) and refreshExternalAuth(...).

OAuth controller

import { createController } from 'remix/router'

export default createController(routes.auth.google, {
  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(databaseContext)
      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.index.href())
    },
  },
})

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, X, and Atmosphere support refresh-token exchange. If the provider does not rotate the refresh token, the refreshed bundle preserves the current one.

async function refreshGoogleTokens({ get }) {
  let db = get(databaseContext)
  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 middleware protection

Apply requireAuth() as controller middleware to every action in one controller:

import { createController } from 'remix/router'
import { requireAuth } from 'remix/middleware/auth'

export default createController(routes.account, {
  middleware: [requireAuth()],
  actions: {
    index() {
      /* guaranteed authenticated */
    },
  },
})

Nested route maps need their own explicit protection:

// app/router.ts
router.map(routes.account, accountController)
router.map(routes.account.settings, accountSettingsController)

// app/actions/account/settings/controller.tsx
export default createController(routes.account.settings, {
  middleware: [requireAuth()],
  actions: {
    index() {
      /* guaranteed authenticated */
    },
    update() {
      /* guaranteed authenticated */
    },
  },
})

Stacking middleware

Combine auth checks with role checks:

export default createController(routes.admin, {
  middleware: [requireAuth(), requireAdmin()],
  actions: {
    index() {
      /* requires auth + admin */
    },
  },
})

Action middleware protection

Apply middleware to a single route:

import { Auth, requireAuth } from 'remix/middleware/auth'

router.get(routes.account.index, {
  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)
    },
  })
}