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:
- A Remix V3 project with a router set up (see the fetch-router tutorial)
- Session middleware configured (see the session-middleware tutorial)
- A database with a
userstable (this tutorial uses pseudocode for database calls)
What You Will Build
- An email/password login form that verifies credentials against your database
- A "Sign in with GitHub" button that uses OAuth 2.0
- A shared post-login flow that stores the user in the session
- 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 setupStep 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.
// 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.
// 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.
// 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:
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:
// 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:
GITHUB_CLIENT_ID=your_client_id_here
GITHUB_CLIENT_SECRET=your_client_secret_here
GITHUB_REDIRECT_URI=http://localhost:3000/auth/github/callbackStep 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).
// 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:
// 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:
// 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',
})// 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:
- Visit
http://localhost:3000/login - Enter an email and password for an existing user in your database
- Submit the form
- You should be redirected to
/
GitHub login:
- Visit
http://localhost:3000/login - Click "Sign in with GitHub"
- You should be redirected to GitHub's authorization page
- Authorize the application
- You should be redirected back to
/
Logout:
Create a form that posts to /logout:
<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:
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:
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
| Function | Purpose |
|---|---|
createCredentialsAuthProvider | Defines how to extract and verify email/password credentials |
verifyCredentials | Runs the parse-then-verify flow for credentials |
createGitHubAuthProvider | Configures GitHub OAuth with client ID, secret, and scopes |
startExternalAuth | Redirects the user to the OAuth provider |
finishExternalAuth | Handles the callback, exchanges the code for tokens, fetches the profile |
completeAuth | Convenience function that finalizes auth and returns the session |
Next Steps
- Protect routes with auth-middleware -- Add route protection so only logged-in users can access certain pages.
- API Reference -- Full documentation of every provider, function, and type.