| name | epic-auth |
| description | Guide on authentication, sessions, OAuth, 2FA, and passkeys for Epic Stack |
| categories | ["authentication","sessions","oauth","security"] |
Epic Stack: Authentication
When to use this skill
Use this skill when you need to:
- Implement user authentication
- Work with sessions and cookies
- Configure OAuth providers (GitHub, Google, etc.)
- Implement 2FA (Two-Factor Authentication)
- Implement WebAuthn/Passkeys
- Handle login, signup, logout flows
- Manage email verification
- Implement password reset
Patterns and conventions
Authentication Philosophy
Following Epic Web principles:
Least privilege - Users should only have access to what they need, when they
need it. Sessions should have minimal permissions and expire appropriately.
Don't grant more access than necessary.
Design to fail fast and early - Validate authentication and authorization as
early as possible. Check session validity immediately, verify permissions before
processing requests, and return clear errors quickly.
Example - Least privilege in sessions:
const session = await prisma.session.create({
data: {
expirationDate: getSessionExpirationDate(),
userId,
},
})
const session = await prisma.session.create({
data: {
expirationDate: getSessionExpirationDate(),
userId,
userRole: 'admin',
permissions: ['all'],
},
})
Example - Fail fast authentication:
export async function loader({ request }: Route.LoaderArgs) {
const userId = await requireUserId(request)
await requireUserWithPermission(request, 'read:note:own')
const notes = await prisma.note.findMany({
where: { ownerId: userId },
})
return { notes }
}
export async function loader({ request }: Route.LoaderArgs) {
const notes = await prisma.note.findMany()
const userId = await getUserId(request)
if (!userId) {
throw redirect('/login')
}
}
Cookie-based Sessions
Epic Stack uses cookie-based sessions for authentication. Sessions are stored in
the database and identified by signed cookies.
Session configuration:
import { createCookieSessionStorage } from 'react-router'
export const authSessionStorage = createCookieSessionStorage({
cookie: {
name: 'en_session',
sameSite: 'lax',
path: '/',
httpOnly: true,
secrets: process.env.SESSION_SECRET.split(','),
secure: process.env.NODE_ENV === 'production',
},
})
Get current user
Server-side:
import { getUserId, requireUserId } from '#app/utils/auth.server.ts'
const userId = await getUserId(request)
const userId = await requireUserId(request)
const userId = await requireUserId(request, { redirectTo: '/custom-login' })
import { requireAnonymous } from '#app/utils/auth.server.ts'
await requireAnonymous(request)
Client-side:
import { useOptionalUser, useUser } from '#app/utils/user.ts'
const user = useOptionalUser()
const user = useUser()
Login with Email/Password
Validation schema:
const LoginSchema = z.object({
username: UsernameSchema,
password: z.string().min(1, 'Password is required'),
redirectTo: z.string().optional(),
remember: z.boolean().optional(),
})
Login action (fail fast):
import { login } from '#app/utils/auth.server.ts'
import { handleNewSession } from './login.server.ts'
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
const submission = await parseWithZod(formData, {
schema: LoginSchema,
})
if (submission.status !== 'success') {
return data({ result: submission.reply() }, { status: 400 })
}
const { username, password, redirectTo, remember } = submission.value
const session = await login({ username, password })
if (!session) {
return data(
{
result: submission.reply({
formErrors: ['Invalid username or password'],
}),
},
{ status: 400 },
)
}
return handleNewSession({
request,
session,
redirectTo,
remember: remember ?? false,
})
}
Signup with Email/Password
Signup action:
import { signup } from '#app/utils/auth.server.ts'
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
const session = await signup({
email,
username,
password,
name,
})
}
OAuth Providers (GitHub, Google, etc.)
Epic Stack uses remix-auth for OAuth providers.
Configure provider (GitHub example):
import { GitHubStrategy } from 'remix-auth-github'
export class GitHubProvider implements AuthProvider {
getAuthStrategy() {
return new GitHubStrategy(
{
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback',
},
async ({ profile }) => {
return {
id: profile.id,
email: profile.emails[0].value,
username: profile.displayName,
name: profile.displayName,
}
},
)
}
}
Callback handler:
export async function loader({ request, params }: Route.LoaderArgs) {
const providerName = ProviderNameSchema.parse(params.provider)
const authResult = await authenticator.authenticate(providerName, request)
if (!authResult.success) {
throw redirectWithToast('/login', {
title: 'Auth Failed',
description: `Error authenticating with ${providerName}`,
type: 'error',
})
}
const { data: profile } = authResult
const existingConnection = await prisma.connection.findUnique({
where: {
providerName_providerId: {
providerName,
providerId: String(profile.id),
},
},
})
if (existingConnection) {
return makeSession({ request, userId: existingConnection.userId })
}
const user = await prisma.user.findUnique({
where: { email: profile.email.toLowerCase() },
})
if (user) {
await prisma.connection.create({
data: {
providerName,
providerId: String(profile.id),
userId: user.id,
},
})
return makeSession({ request, userId: user.id })
}
}
WebAuthn/Passkeys
Epic Stack supports authentication with passkeys using WebAuthn.
Loader to generate options:
import { generateAuthenticationOptions } from '@simplewebauthn/server'
export async function loader({ request }: Route.LoaderArgs) {
const config = getWebAuthnConfig(request)
const options = await generateAuthenticationOptions({
rpID: config.rpID,
userVerification: 'preferred',
})
const cookieHeader = await passkeyCookie.serialize({
challenge: options.challenge,
})
return Response.json(
{ options },
{
headers: { 'Set-Cookie': cookieHeader },
},
)
}
Action to verify authentication:
import { verifyAuthenticationResponse } from '@simplewebauthn/server'
export async function action({ request }: Route.ActionArgs) {
const cookie = await passkeyCookie.parse(request.headers.get('Cookie'))
if (!cookie?.challenge) {
throw new Error('Authentication challenge not found')
}
const { authResponse } = await request.json()
const passkey = await prisma.passkey.findUnique({
where: { id: authResponse.id },
include: { user: true },
})
const verification = await verifyAuthenticationResponse({
response: authResponse,
expectedChallenge: cookie.challenge,
expectedOrigin: config.origin,
expectedRPID: config.rpID,
credential: {
id: authResponse.id,
publicKey: passkey.publicKey,
counter: Number(passkey.counter),
},
})
if (!verification.verified) {
throw new Error('Authentication verification failed')
}
await prisma.passkey.update({
where: { id: passkey.id },
data: { counter: BigInt(verification.authenticationInfo.newCounter) },
})
const session = await prisma.session.create({
data: {
expirationDate: getSessionExpirationDate(),
userId: passkey.userId,
},
})
return handleNewSession({ request, session, remember: true })
}
Two-Factor Authentication (2FA) with TOTP
Epic Stack uses TOTP (Time-based One-Time Password) para 2FA.
Check if user has 2FA:
const verification = await prisma.verification.findUnique({
where: {
target_type: {
target: session.userId,
type: twoFAVerificationType,
},
},
})
const userHasTwoFactor = Boolean(verification)
Handle session with 2FA:
export async function handleNewSession({
request,
session,
redirectTo,
remember,
}: {
request: Request
session: { userId: string; id: string; expirationDate: Date }
redirectTo?: string
remember: boolean
}) {
const verification = await prisma.verification.findUnique({
where: {
target_type: {
target: session.userId,
type: twoFAVerificationType,
},
},
})
const userHasTwoFactor = Boolean(verification)
if (userHasTwoFactor) {
const verifySession = await verifySessionStorage.getSession()
verifySession.set(unverifiedSessionIdKey, session.id)
verifySession.set(rememberKey, remember)
const redirectUrl = getRedirectToUrl({
request,
type: twoFAVerificationType,
target: session.userId,
redirectTo,
})
return redirect(redirectUrl.toString(), {
headers: {
'set-cookie': await verifySessionStorage.commitSession(verifySession),
},
})
} else {
const authSession = await authSessionStorage.getSession(
request.headers.get('cookie'),
)
authSession.set(sessionKey, session.id)
return redirect(safeRedirect(redirectTo), {
headers: {
'set-cookie': await authSessionStorage.commitSession(authSession, {
expires: remember ? session.expirationDate : undefined,
}),
},
})
}
}
Verify 2FA code:
import { prepareTOTP, verifyTOTP } from '@epic-web/totp'
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
const submission = await parseWithZod(formData, {
schema: VerifySchema,
})
if (submission.status !== 'success') {
return data({ result: submission.reply() }, { status: 400 })
}
const { code } = submission.value
const verifySession = await verifySessionStorage.getSession(
request.headers.get('cookie'),
)
const target = verifySession.get(targetKey)
const type = verifySession.get(typeKey)
if (!target || !type) {
throw redirect('/login')
}
const verification = await prisma.verification.findUnique({
where: { target_type: { target, type } },
select: {
secret: true,
algorithm: true,
period: true,
digits: true,
},
})
if (!verification) {
throw redirect('/login')
}
const isValid = verifyTOTP({
otp: code,
secret: verification.secret,
algorithm: verification.algorithm as any,
period: verification.period,
digits: verification.digits,
})
if (!isValid) {
return data(
{
result: submission.reply({
formErrors: ['Invalid code'],
}),
},
{ status: 400 },
)
}
return handleVerification({ request, submission })
}
Email Verification
Epic Stack uses TOTP codes sent via email for verification.
Prepare verification:
import { prepareVerification } from './verify.server.ts'
const { verifyUrl, redirectTo, otp } = await prepareVerification({
period: 10 * 60,
request,
type: 'onboarding',
target: email,
})
await sendEmail({
to: email,
subject: 'Welcome!',
react: <VerificationEmail verifyUrl={verifyUrl.toString()} otp={otp} />,
})
return redirect(redirectTo.toString())
Verify code:
export async function loader({ request }: Route.LoaderArgs) {
const verifySession = await verifySessionStorage.getSession(
request.headers.get('cookie'),
)
const target = verifySession.get(targetKey)
const type = verifySession.get(typeKey)
if (!target || !type) {
throw redirect('/signup')
}
return { target, type }
}
export async function action({ request }: Route.ActionArgs) {
}
Password Reset
Request reset:
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
const submission = await parseWithZod(formData, {
schema: ForgotPasswordSchema,
})
if (submission.status !== 'success') {
return data({ result: submission.reply() }, { status: 400 })
}
const { email } = submission.value
const { verifyUrl, redirectTo, otp } = await prepareVerification({
period: 10 * 60,
request,
type: 'reset-password',
target: email,
})
await sendEmail({
to: email,
subject: 'Reset your password',
react: <ResetPasswordEmail verifyUrl={verifyUrl.toString()} otp={otp} />,
})
return redirect(redirectTo.toString())
}
Reset password:
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
const submission = await parseWithZod(formData, {
schema: ResetPasswordSchema,
})
const { password } = submission.value
await resetUserPassword({
username,
password,
})
}
Logout
import { logout } from '#app/utils/auth.server.ts'
export async function action({ request }: Route.ActionArgs) {
return logout({ request, redirectTo: '/' })
}
Session Management
Create session:
const session = await prisma.session.create({
data: {
expirationDate: getSessionExpirationDate(),
userId,
},
select: { id: true, expirationDate: true },
})
Session expiration:
export const SESSION_EXPIRATION_TIME = 1000 * 60 * 60 * 24 * 30
export const getSessionExpirationDate = () =>
new Date(Date.now() + SESSION_EXPIRATION_TIME)
Destroy session:
await prisma.session.deleteMany({ where: { id: sessionId } })
Destroy all user sessions:
await prisma.session.deleteMany({ where: { userId } })
Common examples
Example 1: Complete login
export async function action({ request }: Route.ActionArgs) {
const formData = await request.formData()
await checkHoneypot(formData)
const submission = await parseWithZod(formData, {
schema: LoginSchema,
})
if (submission.status !== 'success') {
return data({ result: submission.reply() }, { status: 400 })
}
const { username, password, redirectTo, remember } = submission.value
const session = await login({ username, password })
if (!session) {
return data(
{
result: submission.reply({
formErrors: ['Invalid username or password'],
}),
},
{ status: 400 },
)
}
return handleNewSession({
request,
session,
redirectTo,
remember: remember ?? false,
})
}
Example 2: Protect route
export async function loader({ request }: Route.LoaderArgs) {
const userId = await requireUserId(request)
const data = await prisma.something.findMany({
where: { userId },
})
return { data }
}
export default function ProtectedRoute({ loaderData }: Route.ComponentProps) {
return <div>{/* Datos protegidos */}</div>
}
Example 3: Route only for unauthenticated users
export async function loader({ request }: Route.LoaderArgs) {
await requireAnonymous(request)
return null
}
Common mistakes to avoid
- ❌ Delayed authentication checks: Validate authentication and
authorization as early as possible - fail fast
- ❌ Granting excessive privileges: Follow least privilege - only grant
access to what's needed, when it's needed
- ❌ Storing too much in sessions: Store minimal data in sessions (just user
ID), check permissions from database
- ❌ Not verifying session on each request: Always use
getUserId or
requireUserId in protected loaders/actions
- ❌ Not handling 2FA correctly: Verify if user has 2FA before creating
session
- ❌ Not destroying expired sessions: Sessions must be verified against
expirationDate - check early
- ❌ Not using
handleNewSession: This helper correctly handles 2FA and
cookie creation
- ❌ Forgetting to handle
remember: Make sure to respect user preference
- ❌ Not validating OAuth callbacks: Always validate that provider exists
and result is successful - fail fast
- ❌ Not linking OAuth accounts: If email exists, link the account instead
of creating duplicate
- ❌ Not updating counter in passkeys: Always update counter after
successful verification
References