Skip to content

Tutorial: Email/Password and GitHub Login

In this tutorial, you will build a complete authentication system with two login methods: a traditional email/password form and "Sign in with GitHub". By the end, you will understand how to use verifyCredentials, startExternalAuth, finishExternalAuth, and completeAuth.

Prerequisites

Before starting, you should have:

What You Will Build

  1. An email/password login form that verifies credentials against your database
  2. A "Sign in with GitHub" button that uses OAuth 2.0
  3. A shared post-login flow that stores the user in the session
  4. A logout route that destroys the session

Step 1: Set Up the Project Structure

Create the files you will work with:

app/
  auth/
    providers.ts    ← Auth provider configuration
    routes.ts       ← Auth-related route definitions
    handlers.ts     ← Login, callback, and logout handlers
  session.ts        ← Session and cookie setup (from session-middleware tutorial)
  server.ts         ← Router setup

Step 2: Configure Session Infrastructure

If you have not already set up sessions, create the cookie and storage backend. Authentication depends on sessions to remember who is logged in.

ts
// app/session.ts
import { createCookie } from 'remix/cookie'
import { createCookieSessionStorage } from 'remix/session/cookie-storage'

export let sessionCookie = createCookie('__session', {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'Lax',
  secrets: [process.env.SESSION_SECRET!],
  maxAge: 60 * 60 * 24 * 7, // 1 week
})

export let sessionStorage = createCookieSessionStorage(sessionCookie)

Step 3: Create the Credentials Provider

The credentials provider tells Remix how to extract login data from a form submission and how to verify it against your database.

ts
// app/auth/providers.ts
import { createCredentialsAuthProvider } from 'remix/auth'
import bcrypt from 'bcrypt'

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

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

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

    // Compare the submitted password with the stored hash
    let valid = await bcrypt.compare(password, user.passwordHash)
    if (!valid) return null

    // Return the identity object -- this is what gets stored
    return {
      id: user.id,
      email: user.email,
      name: user.name,
      role: user.role,
    }
  },
})

The parse function runs first and extracts raw credentials from the request. The verify function then checks those credentials. If verification fails, it returns null.

Step 4: Create the Email/Password Login Handler

Now wire up a route handler that uses verifyCredentials to authenticate the user.

ts
// app/auth/handlers.ts
import { verifyCredentials } from 'remix/auth'
import { Session } from 'remix/session'
import { credentialsAuth } from './providers.ts'

export async function handleLogin({ context }) {
  // verifyCredentials calls parse() then verify() on the provider
  let identity = await verifyCredentials(credentialsAuth, context)

  if (!identity) {
    // Credentials were invalid -- show an error
    let session = context.get(Session)
    session.flash('error', 'Invalid email or password.')
    return new Response(null, {
      status: 302,
      headers: { Location: '/login' },
    })
  }

  // Credentials were valid -- store the user ID in the session
  let session = context.get(Session)
  session.set('userId', identity.id)

  // Regenerate the session ID to prevent session fixation attacks.
  // Session fixation is when an attacker sets a known session ID
  // before the user logs in, then hijacks the session afterward.
  session.regenerateId()

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

Step 5: Create the Login Form

Create a simple HTML login form that posts to the login route:

ts
export function showLoginForm({ context }) {
  let session = context.get(Session)
  let error = session.get('error') // Flash message, consumed on read

  return new Response(
    `<!DOCTYPE html>
    <html>
    <body>
      <h1>Log In</h1>
      ${error ? `<p style="color: red">${error}</p>` : ''}

      <form method="POST" action="/login">
        <label>Email <input type="email" name="email" required /></label>
        <label>Password <input type="password" name="password" required /></label>
        <button type="submit">Log In</button>
      </form>

      <hr />
      <a href="/auth/github">Sign in with GitHub</a>
    </body>
    </html>`,
    { headers: { 'Content-Type': 'text/html' } },
  )
}

Step 6: Set Up the GitHub OAuth Provider

To add GitHub login, you need a GitHub OAuth application. Go to GitHub > Settings > Developer settings > OAuth Apps > New OAuth App and register your app with:

  • Homepage URL: http://localhost:3000
  • Authorization callback URL: http://localhost:3000/auth/github/callback

Then create the provider:

ts
// app/auth/providers.ts (add to existing file)
import { createGitHubAuthProvider } from 'remix/auth'

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

Add the environment variables to your .env file:

bash
GITHUB_CLIENT_ID=your_client_id_here
GITHUB_CLIENT_SECRET=your_client_secret_here
GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callback

Step 7: Create the OAuth Handlers

The OAuth flow requires two handlers: one to start the flow (redirect to GitHub) and one to finish it (handle the callback).

ts
// app/auth/handlers.ts (add to existing file)
import { startExternalAuth, finishExternalAuth } from 'remix/auth'
import { githubAuth } from './providers.ts'

// Step 1: Redirect to GitHub's authorization page
export async function handleGitHubLogin({ context }) {
  // startExternalAuth generates a state parameter for CSRF protection,
  // stores it in the session, and returns a redirect response
  return startExternalAuth(githubAuth, context)
}

// Step 2: Handle the callback from GitHub
export async function handleGitHubCallback({ context }) {
  // finishExternalAuth extracts the authorization code from the URL,
  // verifies the state parameter, exchanges the code for tokens,
  // and fetches the user's profile from GitHub
  let result = await finishExternalAuth(githubAuth, context)

  if (!result) {
    // Something went wrong -- expired code, invalid state, etc.
    let session = context.get(Session)
    session.flash('error', 'GitHub authentication failed. Please try again.')
    return new Response(null, {
      status: 302,
      headers: { Location: '/login' },
    })
  }

  // result.profile contains GitHub-specific fields:
  //   id, login, email, name, avatar_url, html_url, bio, etc.
  // result.tokens contains access_token, token_type, scope

  // Find or create the user in your database
  let user = await db.findOne(users, {
    where: eq(users.columns.githubId, result.profile.id),
  })

  if (!user) {
    user = await db.insert(users, {
      email: result.profile.email,
      name: result.profile.name,
      githubId: result.profile.id,
      avatarUrl: result.profile.avatar_url,
    })
  }

  // Store the user in the session
  let session = context.get(Session)
  session.set('userId', user.id)
  session.regenerateId()

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

Step 8: Create the Logout Handler

Logging out is simple -- destroy the session:

ts
// app/auth/handlers.ts (add to existing file)

export async function handleLogout({ context }) {
  let session = context.get(Session)
  session.destroy()

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

Step 9: Wire Up the Routes

Define the routes and connect them to the handlers:

ts
// app/auth/routes.ts
import { route } from 'remix/fetch-router/routes'

export let authRoutes = route({
  login: '/login',
  logout: '/logout',
  github: '/auth/github',
  githubCallback: '/auth/github/callback',
})
ts
// app/server.ts
import { createRouter } from 'remix/fetch-router'
import { session } from 'remix/session-middleware'
import { formData } from 'remix/form-data-middleware'
import { sessionCookie, sessionStorage } from './session.ts'
import { authRoutes } from './auth/routes.ts'
import {
  showLoginForm,
  handleLogin,
  handleGitHubLogin,
  handleGitHubCallback,
  handleLogout,
} from './auth/handlers.ts'

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

// Login page (GET) and login action (POST)
router.get(authRoutes.login, showLoginForm)
router.post(authRoutes.login, handleLogin)

// GitHub OAuth
router.get(authRoutes.github, handleGitHubLogin)
router.get(authRoutes.githubCallback, handleGitHubCallback)

// Logout
router.post(authRoutes.logout, handleLogout)

Step 10: Test the Flow

Start your development server and test both login methods.

Email/password login:

  1. Visit http://localhost:3000/login
  2. Enter an email and password for an existing user in your database
  3. Submit the form
  4. You should be redirected to /

GitHub login:

  1. Visit http://localhost:3000/login
  2. Click "Sign in with GitHub"
  3. You should be redirected to GitHub's authorization page
  4. Authorize the application
  5. You should be redirected back to /

Logout:

Create a form that posts to /logout:

html
<form method="POST" action="/logout">
  <button type="submit">Log Out</button>
</form>

Adding More Providers

Adding another OAuth provider (e.g. Google) follows the same pattern:

ts
import { createGoogleAuthProvider, startExternalAuth, finishExternalAuth } 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'],
})

router.get(googleLoginRoute, async ({ context }) => {
  return startExternalAuth(googleAuth, context)
})

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

  // result.profile is GoogleProfile with sub, email, name, picture, etc.
  let user = await findOrCreateUser(result.profile.email, result.profile.name)

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

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

The flow functions (startExternalAuth, finishExternalAuth) work identically for every external provider. Only the provider configuration changes.

Using completeAuth

The completeAuth function is a convenience helper that finalizes pending auth state and returns the session. It can replace the manual context.get(Session) call in callback handlers:

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

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

  // completeAuth returns the session directly
  let session = completeAuth(context)
  session.set('userId', result.profile.id)

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

Summary

FunctionPurpose
createCredentialsAuthProviderDefines how to extract and verify email/password credentials
verifyCredentialsRuns the parse-then-verify flow for credentials
createGitHubAuthProviderConfigures GitHub OAuth with client ID, secret, and scopes
startExternalAuthRedirects the user to the OAuth provider
finishExternalAuthHandles the callback, exchanges the code for tokens, fetches the profile
completeAuthConvenience function that finalizes auth and returns the session

Next Steps

Released under the MIT License.