Skip to content

Authentication

This guide covers everything you need to build a production authentication system in Remix V3. It goes deeper than the tutorial, covering OAuth providers, bearer token auth for APIs, role-based access control, and custom OIDC providers.

Overview

Remix V3 authentication is built from three layers:

  1. Auth providers -- Define how users prove their identity (password, Google, GitHub, etc.).
  2. Auth middleware -- Runs on every request to determine who the current user is.
  3. Auth schemes -- Define where to look for credentials (session cookie, bearer token, API key).

Each layer is independent and composable. You can use one provider or many, one scheme or several, and mix them however your application requires.

Credentials Authentication

Credentials authentication is the classic email-and-password flow. The user submits a form, the server checks the credentials against the database, and if they match, the user is logged in.

Setting Up the Provider

ts
import {
  createCredentialsAuthProvider,
  verifyCredentials,
  completeAuth,
} from 'remix/auth'
import { Session } from 'remix/session'
import bcrypt from 'bcrypt'

let credentialsAuth = createCredentialsAuthProvider({
  name: 'credentials',

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

  // Verify the credentials against your database
  async verify({ email, password }) {
    let user = await db.findOne(users, {
      where: eq(users.columns.email, email),
    })
    if (!user) return null

    let valid = await bcrypt.compare(password, user.password_hash)
    if (!valid) return null

    return {
      id: user.id,
      email: user.email,
      name: user.name,
      role: user.role,
    }
  },
})

The parse function extracts credentials from the request context. The verify function checks them and returns a user object on success or null on failure.

Password Hashing with bcrypt

Never store passwords in plain text. Use bcrypt to hash passwords before storing them:

bash
npm install bcrypt
npm install -D @types/bcrypt
ts
import bcrypt from 'bcrypt'

// When registering a new user
let passwordHash = await bcrypt.hash(plainPassword, 12)
await db.create(users, {
  email: 'user@example.com',
  password_hash: passwordHash,
  name: 'Jane Doe',
  role: 'user',
  created_at: Date.now(),
})

// When verifying during login
let valid = await bcrypt.compare(plainPassword, user.password_hash)

The second argument to bcrypt.hash() is the salt rounds -- higher values are more secure but slower. 12 is a good default for production.

Never use SHA-256 or MD5 for passwords

General-purpose hash functions like SHA-256, SHA-512, and MD5 are designed to be fast. That makes them terrible for passwords, because attackers can try billions of guesses per second. Use bcrypt, argon2, or scrypt instead -- they are intentionally slow.

Handling Login

ts
import { verifyCredentials } from 'remix/auth'
import { Session } from 'remix/session'

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

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

  // Store the user ID in the session
  let session = context.get(Session)
  session.set('userId', result.id)
  session.flash('message', `Welcome back, ${result.name}!`)

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

Handling Registration

ts
import { object, string, parseSafe } from 'remix/data-schema'
import { email, minLength } from 'remix/data-schema/checks'

let RegisterSchema = object({
  name: string().pipe(minLength(1, 'Name is required')),
  email: string().pipe(email('Invalid email address')),
  password: string().pipe(minLength(8, 'Password must be at least 8 characters')),
  confirmPassword: string(),
}).refine(
  (data) => data.password === data.confirmPassword,
  'Passwords do not match',
)

// POST /register
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) {
    return showRegisterForm(formatErrors(result.issues), values)
  }

  // Check for existing user
  let existing = await db.findOne(users, {
    where: eq(users.columns.email, result.value.email),
  })
  if (existing) {
    return showRegisterForm({ email: 'An account with this email already exists.' }, values)
  }

  // Create the user
  let passwordHash = await bcrypt.hash(result.value.password, 12)
  let user = await db.create(users, {
    email: result.value.email,
    password_hash: passwordHash,
    name: result.value.name,
    role: 'user',
    created_at: Date.now(),
  })

  // Log them in immediately
  let session = context.get(Session)
  session.set('userId', user.id)
  session.flash('message', 'Welcome!')

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

Handling Logout

ts
// POST /logout
router.map(logoutRoute, async ({ context }) => {
  let session = context.get(Session)
  session.destroy()

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

OAuth 2.0 Authentication

OAuth 2.0 lets users sign in with their existing accounts from providers like Google, GitHub, and others. Instead of managing passwords yourself, you delegate authentication to a trusted third party.

How OAuth 2.0 Works

The OAuth 2.0 authorization code flow has five steps:

  1. Your app redirects to the provider -- The user clicks "Sign in with Google" and is sent to Google's login page with your app's client ID and a redirect URL.
  2. The user authorizes your app -- The user logs in to Google and grants your app permission to access basic profile information.
  3. The provider redirects back -- Google sends the user back to your redirect URL with a temporary authorization code.
  4. Your app exchanges the code for tokens -- Your server sends the code (along with your client secret) to Google's token endpoint and receives an access token.
  5. Your app fetches user info -- Your server uses the access token to call Google's user info API and gets the user's profile (name, email, avatar).

Remix handles steps 1, 3, 4, and 5 automatically. You just configure the provider and handle what happens after authentication.

Google OAuth

Register your application in the Google Cloud Console to get a client ID and secret. Set the authorized redirect URI to http://localhost:3000/auth/google/callback for development.

ts
import {
  createGoogleAuthProvider,
  startExternalAuth,
  finishExternalAuth,
  completeAuth,
} from 'remix/auth'

let googleAuth = createGoogleAuthProvider({
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  redirectUri: process.env.GOOGLE_REDIRECT_URI!,
  scopes: ['openid', 'email', 'profile'],
})

You need two routes -- one to start the flow and one to handle the callback:

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

let googleLoginRoute = get('/auth/google')
let googleCallbackRoute = get('/auth/google/callback')

// Step 1: Redirect to Google
router.map(googleLoginRoute, async ({ context }) => {
  return startExternalAuth(googleAuth, context)
})

// Step 3-5: Handle the callback
router.map(googleCallbackRoute, async ({ context }) => {
  let result = await finishExternalAuth(googleAuth, context)

  if (!result) {
    return new Response('Authentication failed', { status: 401 })
  }

  // result contains the user's profile from Google
  // { sub: '...', email: '...', name: '...', picture: '...' }

  // Find or create a local user
  let user = await db.findOne(users, {
    where: eq(users.columns.email, result.email),
  })

  if (!user) {
    user = await db.create(users, {
      email: result.email,
      name: result.name,
      password_hash: '', // No password for OAuth users
      role: 'user',
      avatar_url: result.picture,
      created_at: Date.now(),
    })
  }

  // Complete the auth flow by storing user in the session
  let session = context.get(Session)
  session.set('userId', user.id)

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

State parameter for CSRF protection

startExternalAuth automatically generates and stores a state parameter in the session. finishExternalAuth verifies it on callback. This prevents CSRF attacks where an attacker tricks a user into logging in with the attacker's account.

GitHub OAuth

Register an OAuth application in GitHub Developer Settings. Set the callback URL to http://localhost:3000/auth/github/callback.

ts
import {
  createGitHubAuthProvider,
  startExternalAuth,
  finishExternalAuth,
} from 'remix/auth'

let githubAuth = createGitHubAuthProvider({
  clientId: process.env.GITHUB_CLIENT_ID!,
  clientSecret: process.env.GITHUB_CLIENT_SECRET!,
  redirectUri: process.env.GITHUB_REDIRECT_URI!,
  scopes: ['user:email'],
})

// Routes follow the same pattern as Google
router.map(githubLoginRoute, async ({ context }) => {
  return startExternalAuth(githubAuth, context)
})

router.map(githubCallbackRoute, async ({ context }) => {
  let result = await finishExternalAuth(githubAuth, context)

  if (!result) {
    return new Response('Authentication failed', { status: 401 })
  }

  // result: { id: number, login: string, email: string, name: string, avatar_url: string }
  // Find or create user, then set session...
  let user = await findOrCreateFromOAuth('github', result.email, result.name, result.avatar_url)

  let session = context.get(Session)
  session.set('userId', user.id)

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

Other Providers

Remix includes built-in support for several OAuth providers. Each follows the same pattern -- create the provider, start auth, finish auth:

ProviderImportScopes
GooglecreateGoogleAuthProvideropenid, email, profile
GitHubcreateGitHubAuthProvideruser:email
MicrosoftcreateMicrosoftAuthProvideropenid, email, profile
FacebookcreateFacebookAuthProvideremail, public_profile
X (Twitter)createXAuthProviderusers.read, tweet.read
Auth0createAuth0AuthProvideropenid, email, profile
OktacreateOktaAuthProvideropenid, email, profile

All providers are imported from remix/auth:

ts
import {
  createMicrosoftAuthProvider,
  createFacebookAuthProvider,
  createXAuthProvider,
  createAuth0AuthProvider,
  createOktaAuthProvider,
} from 'remix/auth'

Custom OIDC Providers

If your identity provider supports OpenID Connect (OIDC) but is not listed above, use the generic OIDC provider:

ts
import { createOIDCAuthProvider } from 'remix/auth'

let corporateAuth = createOIDCAuthProvider({
  name: 'corporate-sso',
  issuer: 'https://sso.yourcompany.com',
  clientId: process.env.SSO_CLIENT_ID!,
  clientSecret: process.env.SSO_CLIENT_SECRET!,
  redirectUri: process.env.SSO_REDIRECT_URI!,
  scopes: ['openid', 'email', 'profile'],
})

The issuer URL is used to discover the provider's authorization, token, and userinfo endpoints automatically via the .well-known/openid-configuration document.

Auth Middleware

Auth middleware runs on every request and determines who the current user is. It does not block requests -- it just attaches the user identity to the context. Use requireAuth to actually block unauthenticated requests.

Session-Based Auth Scheme

Session auth is the most common scheme for web applications. The user's identity is stored in a session cookie.

ts
import {
  auth,
  requireAuth,
  createSessionAuthScheme,
  Auth,
} from 'remix/auth-middleware'
import { Session } from 'remix/session'

let sessionScheme = createSessionAuthScheme({
  name: 'session',

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

  // Verify the identifier is still valid
  async verify(userId) {
    let user = await db.findOne(users, {
      where: eq(users.columns.id, userId),
    })
    if (!user) return null
    return {
      id: user.id,
      email: user.email,
      name: user.name,
      role: user.role,
    }
  },
})

Bearer Token Auth Scheme

Bearer token auth is common for APIs. The client sends a token in the Authorization header.

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

let bearerScheme = createBearerTokenAuthScheme({
  name: 'bearer',

  // Verify the token and return the user
  async verify(token) {
    // Check against your token store
    let tokenRecord = await db.findOne(apiTokens, {
      where: eq(apiTokens.columns.token, token),
    })
    if (!tokenRecord) return null

    // Check expiry
    if (tokenRecord.expires_at && tokenRecord.expires_at < Date.now()) {
      return null
    }

    let user = await db.findOne(users, {
      where: eq(users.columns.id, tokenRecord.user_id),
    })
    if (!user) return null

    return {
      id: user.id,
      email: user.email,
      name: user.name,
      role: user.role,
    }
  },
})

Clients send the token like this:

Authorization: Bearer eyJhbGciOiJIUzI1NiIs...

API Key Auth Scheme

API key auth uses a custom header or query parameter:

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

let apiKeyScheme = createAPIAuthScheme({
  name: 'api-key',
  header: 'X-API-Key',

  async verify(apiKey) {
    let record = await db.findOne(apiKeys, {
      where: eq(apiKeys.columns.key, apiKey),
    })
    if (!record) return null

    return {
      id: record.user_id,
      name: record.label,
      role: 'api',
    }
  },
})

Combining Multiple Schemes

Pass multiple schemes to the auth middleware. It tries each one in order and uses the first that succeeds:

ts
let authMiddleware = auth({
  schemes: [sessionScheme, bearerScheme, apiKeyScheme],
})

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

This lets your application serve both browser users (session cookies) and API clients (bearer tokens or API keys) from the same routes.

Protecting Routes

requireAuth Middleware

The requireAuth middleware blocks unauthenticated requests. It returns a 401 response (or redirects to a login page) if no auth scheme succeeded.

ts
let requireLogin = requireAuth({
  // For browser requests, redirect to the login page
  onUnauthenticated(request) {
    let url = new URL(request.url)
    return new Response(null, {
      status: 302,
      headers: {
        Location: `/login?returnTo=${encodeURIComponent(url.pathname)}`,
      },
    })
  },
})

Apply it to individual routes:

ts
router.map(profileRoute, { middleware: [requireLogin] }, async ({ context }) => {
  let user = context.get(Auth)
  return html`<h1>Hello, ${user.name}!</h1>`
})

Or apply it to a group of routes:

ts
// Protect all /admin/* routes
router.map(adminRoutes, { middleware: [requireLogin] }, async ({ context }) => {
  // ...
})

Reading the Current User

The Auth context key gives you access to the current user in any handler, whether or not the route is protected:

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

router.map(homeRoute, async ({ context }) => {
  let user = context.get(Auth) // null if not logged in

  if (user) {
    return html`<p>Welcome, ${user.name}!</p>`
  }
  return html`<p>Please <a href="/login">log in</a>.</p>`
})

Role-Based Access Control

Once you have auth working, you can restrict access based on user roles.

Defining Roles

Store roles in your users table:

ts
export const users = table({
  name: 'users',
  columns: {
    id: c.integer(),
    email: c.text(),
    password_hash: c.text(),
    name: c.text(),
    role: c.enum(['user', 'editor', 'admin']),
    created_at: c.integer(),
  },
})

Creating a Role Guard

Build a middleware factory that checks the user's role:

ts
import type { Middleware } from 'remix/fetch-router'
import { Auth } from 'remix/auth-middleware'

function requireRole(...roles: string[]): Middleware {
  return async (context, next) => {
    let user = context.get(Auth)

    if (!user) {
      return new Response('Unauthorized', { status: 401 })
    }

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

    return next()
  }
}

Use it on routes:

ts
// Only admins can access user management
router.map(manageUsersRoute, { middleware: [requireRole('admin')] }, async ({ context }) => {
  let allUsers = await db.findMany(users)
  // ...
})

// Editors and admins can create content
router.map(createPostRoute, { middleware: [requireRole('editor', 'admin')] }, async ({ context }) => {
  // ...
})

Permission-Based Access Control

For finer-grained control, use a permissions model:

ts
let rolePermissions: Record<string, string[]> = {
  user: ['read:posts', 'write:comments'],
  editor: ['read:posts', 'write:posts', 'write:comments', 'delete:own-posts'],
  admin: ['read:posts', 'write:posts', 'write:comments', 'delete:any-posts', 'manage:users'],
}

function requirePermission(...permissions: string[]): Middleware {
  return async (context, next) => {
    let user = context.get(Auth)
    if (!user) return new Response('Unauthorized', { status: 401 })

    let userPermissions = rolePermissions[user.role] ?? []
    let hasAll = permissions.every((p) => userPermissions.includes(p))

    if (!hasAll) return new Response('Forbidden', { status: 403 })

    return next()
  }
}

// Usage
router.map(deletePostRoute, { middleware: [requirePermission('delete:any-posts')] }, handler)

Complete Example

Here is a full authentication setup with credentials login, Google OAuth, and protected routes:

ts
// app/auth.ts
import {
  createCredentialsAuthProvider,
  createGoogleAuthProvider,
  verifyCredentials,
  startExternalAuth,
  finishExternalAuth,
} from 'remix/auth'
import {
  auth,
  requireAuth,
  createSessionAuthScheme,
  Auth,
} from 'remix/auth-middleware'
import { Session } from 'remix/session'
import bcrypt from 'bcrypt'

// --- Providers ---

export let credentialsAuth = createCredentialsAuthProvider({
  name: 'credentials',
  parse(context) {
    let data = context.get(FormData)
    return {
      email: data.get('email') as string,
      password: data.get('password') as string,
    }
  },
  async verify({ email, password }) {
    let user = await db.findOne(users, {
      where: eq(users.columns.email, email),
    })
    if (!user) return null
    let valid = await bcrypt.compare(password, user.password_hash)
    if (!valid) return null
    return { id: user.id, email: user.email, name: user.name, role: user.role }
  },
})

export let googleAuth = createGoogleAuthProvider({
  clientId: process.env.GOOGLE_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
  redirectUri: process.env.GOOGLE_REDIRECT_URI!,
  scopes: ['openid', 'email', 'profile'],
})

// --- Schemes ---

export let sessionScheme = createSessionAuthScheme({
  name: 'session',
  read(session) {
    return session.get('userId') ?? null
  },
  async verify(userId) {
    let user = await db.findOne(users, {
      where: eq(users.columns.id, userId),
    })
    if (!user) return null
    return { id: user.id, email: user.email, name: user.name, role: user.role }
  },
})

// --- Middleware ---

export let authMiddleware = auth({ schemes: [sessionScheme] })

export let requireLogin = requireAuth({
  onUnauthenticated(request) {
    let url = new URL(request.url)
    return new Response(null, {
      status: 302,
      headers: { Location: `/login?returnTo=${encodeURIComponent(url.pathname)}` },
    })
  },
})
ts
// app/server.ts
import { createRouter } from 'remix/fetch-router'
import { session } from 'remix/session-middleware'
import { authMiddleware, requireLogin, credentialsAuth, googleAuth } from './auth.ts'
import { verifyCredentials, startExternalAuth, finishExternalAuth } from 'remix/auth'
import { Auth } from 'remix/auth-middleware'
import { Session } from 'remix/session'

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

// --- Login ---
router.map(loginRoutes.index, () => showLoginForm())

router.map(loginRoutes.action, async ({ context }) => {
  let result = await verifyCredentials(credentialsAuth, context)
  if (!result) return showLoginForm('Invalid email or password.')

  let session = context.get(Session)
  session.set('userId', result.id)
  return new Response(null, { status: 302, headers: { Location: '/' } })
})

// --- Google OAuth ---
router.map(googleLoginRoute, async ({ context }) => {
  return startExternalAuth(googleAuth, context)
})

router.map(googleCallbackRoute, async ({ context }) => {
  let result = await finishExternalAuth(googleAuth, context)
  if (!result) return new Response('Authentication failed', { status: 401 })

  let user = await findOrCreateFromOAuth(result.email, result.name)
  let session = context.get(Session)
  session.set('userId', user.id)
  return new Response(null, { status: 302, headers: { Location: '/' } })
})

// --- Logout ---
router.map(logoutRoute, async ({ context }) => {
  context.get(Session).destroy()
  return new Response(null, { status: 302, headers: { Location: '/' } })
})

// --- Protected routes ---
router.map(profileRoute, { middleware: [requireLogin] }, async ({ context }) => {
  let user = context.get(Auth)
  return html`
    <h1>Profile</h1>
    <p>Name: ${user.name}</p>
    <p>Email: ${user.email}</p>
    <p>Role: ${user.role}</p>
    <form method="post" action="/logout">
      <button type="submit">Log Out</button>
    </form>
  `
})

Return URL after login

Notice the returnTo query parameter in requireLogin. After the user logs in, redirect them back to the page they originally tried to visit:

ts
router.map(loginRoutes.action, async ({ context, request }) => {
  let result = await verifyCredentials(credentialsAuth, context)
  if (!result) return showLoginForm('Invalid email or password.')

  let session = context.get(Session)
  session.set('userId', result.id)

  let url = new URL(request.url)
  let returnTo = url.searchParams.get('returnTo') || '/'
  return new Response(null, { status: 302, headers: { Location: returnTo } })
})

Released under the MIT License.