Docs

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.

TS
// 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.

TS
// 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.

TS
// 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.api

Guard 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.

TS
// 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):

TS
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).