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
- User clicks on “Login” button
- User enters email on
/login - System sends a 6-digit code to their email
- User enters the code
- 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.
| Function | Module | Purpose |
|---|---|---|
requireUserId(request, session) | @/auth | Returns user ID or redirects to /login |
getUserId(session) | @/user | Returns user ID from session (no redirect) |
getUserById(db, userId) | @/user | Returns user record from database |
useUser() | @/user | Client-side hook to access current user |
Login & Logout
Section titled “Login & Logout”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=/dashboardAfter 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>Protecting Pages
Section titled “Protecting Pages”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.
Accessing User Data
Section titled “Accessing User Data”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) // ...}Session Details
Section titled “Session Details”- Duration: 7 days
- OTP expiration: 5 minutes
- Cookie:
httpOnly,secure,sameSite: strict