Auth & sessions
Two paths. @nifrajs/better-auth is turnkey — mount Better Auth (OAuth, magic links, 2FA, …) into your app in one line. @nifrajs/auth is the framework half — signed-cookie or server-store sessions, route guards, and CSRF — when you want to own identity yourself. Nifra owns the session; you bring (or mount) the who.
Full auth with Better Auth
@nifrajs/better-auth bridges Better Auth into nifra: betterAuth(auth) mounts its handler at /api/auth/* (GET + POST), so every endpoint — sign-in/up/out, OAuth callbacks, session, 2FA, magic links — is served by your nifra server. Read the session with getSession(auth, request) (typed { user, session } | null) or guard a route with requireSession(auth, request, options?) (returns it, or throws a 401/redirect Response). It's declared structurally — no hard dependency on Better Auth, so your tests need no database — and your Better Auth types flow through by inference.
// auth.ts — your configured Better Auth instance (database, providers, …):
import { betterAuth as createBetterAuth } from "better-auth"
export const auth = createBetterAuth({ database: db, emailAndPassword: { enabled: true } })
// server.ts — ONE use() mounts every Better Auth endpoint at /api/auth/*:
import { betterAuth, getSession, requireSession } from "@nifrajs/better-auth"
const app = server()
.use(betterAuth(auth)) // sign-in/up/out, OAuth, 2FA, session…
.get("/me", async (c) => (await requireSession(auth, c.req)).user) // typed; 401 when signed out
// In a loader/action, read the session from the raw Request:
export async function loader({ request }) {
const session = await getSession(auth, request) // { user, session } | null — typed
const { user } = await requireSession(auth, request, { redirectTo: "/login" }) // or guard it
return { user }
}Prefer to own identity (custom password/OAuth, Lucia, …)? Use the session primitives below.
Set up a session manager
createSessions signs the cookie (HMAC, verified constant-time) and always marks it HttpOnly. In store mode the cookie is just an opaque id and the data lives in a SessionStore; in cookie mode (no store) the data is signed into the cookie. Stores mirror the ISR discipline: MemorySessionStore is prod-guarded; KVSessionStore is the durable, shared production store.
// auth.ts — one session manager. LAZY so a route module can import it without shipping it to the
// browser (see the warning below). Store mode keeps data server-side; cookie mode (no store) is stateless.
import { createSessions, MemorySessionStore, KVSessionStore } from "@nifrajs/auth"
let manager
export const getSessions = () => (manager ??= createSessions({
secret: process.env.SESSION_SECRET, // ≥ 16 chars; rotating it invalidates all sessions
store: new MemorySessionStore(), // prod: new KVSessionStore(env.SESSIONS)
// cookie: { secure: false }, // local http dev only
}))Log in & out
A loader can read the session but can't write cookies — so login/logout live in plain Nifra routes that have the full Context. regenerate() rotates the session id on login to defend against fixation; the Set-Cookie rides the redirect.
// server.ts — login/logout are plain nifra routes (full Context → they can WRITE the cookie).
const sessions = getSessions()
app.use(csrf()) // Origin check on unsafe methods
app.post("/api/login", async (c) => {
const { username } = Object.fromEntries(await c.req.formData())
// ... verify the credential (Better Auth / Lucia / your own) ...
const session = await sessions.get(c)
session.set("userId", String(username))
sessions.regenerate(session) // rotate the id on login (fixation defense)
await sessions.commit(c, session)
return redirect("/") // the Set-Cookie rides the redirect
})
app.post("/api/logout", async (c) => {
await sessions.destroy(c, await sessions.get(c))
return redirect("/login")
})
createWebApp({ /* … */ api: sessions }) // inject the manager into loaders as ctx.apiGuard a route
requireSession / requireUser throw a Response (a 302 to redirectTo, or a 401) when the session is missing — nifra returns a thrown Response as-is, so the guard short-circuits the loader.
// A protected route's loader — reads the session and redirects when absent.
import { requireUser } from "@nifrajs/auth" // browser-safe; OK to import in a route module
export async function loader({ request, api }) {
const sessions = api // the manager injected via createWebApp's `api`
const session = await sessions.read(request)
// requireUser throws a 302 to /login when there's no session; nifra returns the thrown Response.
const userId = requireUser(session, "userId", { redirectTo: "/login" })
return { userId }
}⚠️ Never import server-only code into a route module
A route's loader runs only on the server, but its module is also bundled for the browser (for the component) — and the loader is not stripped from that bundle. So a top-level import of the session manager (or a DB client, or anything touching process.env) would ship server code to the client and crash hydration. Reach server resources through ctx.api / ctx.env instead (inject them via createWebApp) — exactly how the manager is passed as api above. requireUser is fine to import: it only builds a Response, no secrets.
CSRF
app.use(csrf({ origins })) rejects any unsafe-method request whose Origin/Referer doesn't match an allowed origin — the recommended defense for cookie-auth. Pair it with the rate-limit middleware on your login route.
Cookie Security Defaults
Nifra's c.set.cookie() applies secure defaults to every cookie:
- HttpOnly — not accessible to JavaScript (prevents XSS theft)
- Secure — sent only over HTTPS (set
{ secure: false }for local http dev) - SameSite=Lax — mitigates CSRF without blocking top-level navigation
- Path=/ — sent on all requests (override with
{ path: '/admin' }if needed)
These are applied to all cookies (sessions, CSRF tokens, preferences) unless explicitly overridden. When developing locally, you must set { secure: false } or the cookie will be rejected by the browser on non-HTTPS connections.
Rate Limiting on Login
The rate-limit middleware (@nifrajs/middleware) protects against brute-force by enforcing IP-based buckets. Critical: if your app is behind a reverse proxy (CDN, load balancer), you must configure trustedProxies so the middleware reads the real client IP from X-Forwarded-For instead of the proxy's IP (which would cause all users to share a rate limit):
app.use(
rateLimit({
key: (c) => "login:" + c.req.header("x-forwarded-for") ?? c.req.header("cf-connecting-ip") ?? c.ip,
limit: 5, // 5 attempts
window: 15 * 60 * 1000, // per 15 minutes
onExceeded: (c) => new Response("Too many login attempts", { status: 429 }),
}),
)
.post("/login", …)Without trustedProxies configured correctly, the middleware will fall back to the proxy's IP address, and your entire user base will share a single rate-limit bucket — defeating the protection. Check your proxy's documentation for how it sets X-Forwarded-For (Cloudflare uses cf-connecting-ip, AWS ALB/NLB use x-forwarded-for).