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.
// doc-check: skip - needs the third-party `better-auth` package + your `db`; install it to run this.
// 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 } from "@nifrajs/auth"
let manager: ReturnType<typeof createSessions> | undefined
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.
// doc-check: skip - fragment: `api` is the session manager createWebApp injected (see setup above).
// 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).