Skip to content

Authentication

Built-in authentication using OTP (One-Time Password) via email. No third-party auth services required. No passwords to store or reset. Users don’t need to remember credentials. Simple, secure by design, and works for most applications.

How it works

  1. User clicks on “Login” button
  2. User enters email on /login
  3. System sends a 6-digit code to their email
  4. User enters the code
  5. Session is created

Authentication requires SESSION_COOKIE_SECRET and OTP_SECRET environment variables. For local development, these are already configured in the .env file. For production, see Cloudflare setup.

Captcha (optional): Enable Cloudflare Turnstile to protect against bots. The captcha appears automatically on the login page when configured. For local development it’s already configured and Turnstile always passes. For production, add a Turnstile widget, then configure CLOUDFLARE_TURNSTILE_PUBLIC_KEY in wrangler.json (under vars) and CLOUDFLARE_TURNSTILE_SECRET_KEY as a secret as stated in Cloudflare setup.

FunctionModulePurpose
requireUserId(request, session)@/authReturns user ID or redirects to /login
getUserId(session)@/userReturns user ID from session (no redirect)
getUserById(db, userId)@/userReturns user record from database
useUser()@/userClient-side hook to access current user

Login and logout buttons are already included in the header (apps/content/routes/layout.tsx).

The /login route handles the entire auth flow. It supports a redirectTo query parameter:

/login?redirectTo=/dashboard

After successful authentication, the user is redirected to the specified URL.

To sign out, send a POST request to /logout:

<Form method="post" action="/logout">
<button type="submit">Sign out</button>
</Form>

Use requireUserId in your loader to restrict access:

import { authContext } from "@/auth/context"
import { requireUserId } from "@/auth"
export async function loader({ request, context }: Route.LoaderArgs) {
const { session } = context.get(authContext)
const userId = await requireUserId(request, session)
// User is authenticated, continue...
}

If not authenticated, the user is redirected to /login with a redirectTo parameter pointing back to the current page.

On the client, use the useUser hook:

import { useUser } from "@/user"
function MyComponent() {
const { data } = useUser()
if (data?.user) {
return <p>Hello, {data.user.email}</p>
}
return <p>Not signed in</p>
}

On the server, use requireUserId and getUserById:

import { authContext } from "@/auth/context"
import { dbContext } from "@/db/context"
import { requireUserId } from "@/auth"
import { getUserById } from "@/user"
export async function loader({ request, context }: Route.LoaderArgs) {
const db = context.get(dbContext)
const { session } = context.get(authContext)
const userId = await requireUserId(request, session)
const user = await getUserById(db, userId)
// ...
}
  • Duration: 7 days
  • OTP expiration: 5 minutes
  • Cookie: httpOnly, secure, sameSite: strict