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

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

Proudly built with Nifra — server-rendered on Cloudflare Pages.MIT