Docs

Auth & sessions

Three paths. @nifrajs/better-auth is turnkey - mount Better Auth (OAuth, magic links, 2FA, …) into your app in one line. @nifrajs/authjs mounts Auth.js (OAuth/OIDC providers, credentials, WebAuthn-ready) the same way, with a framework-agnostic client plus React bindings. @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.
// backend/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 } })

// backend/app.ts - ONE use() mounts every Better Auth endpoint at /api/auth/*:
import { betterAuth, getSession, requireSession } from "@nifrajs/better-auth"
export const backend = 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

// routes/account.backend.ts - a loader or action reads the session from the raw Request:
export const loaderOutput = t.object({ user: t.object({ id: t.string(), name: t.string() }) })
export async function loader({ request }: Route.LoaderArgs) {
  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.

Auth.js, mounted natively

@nifrajs/authjs is the official Auth.js integration: authjs(config) serves /api/auth/* (sign-in, OAuth callbacks, session, sign-out, CSRF) through @auth/core itself - PKCE, state, and token verification stay Auth.js's job, never a reimplementation. Read the session with getSession(request, config) (typed Session | null, works in handlers and loaders) or guard with requireAuthUser(request, config) (returns the user, or throws a 401/redirect). The frontend half is a framework-agnostic client (createAuthClient() - session, sign-in, sign-out) plus <AuthSessionProvider> / useAuthSession() in @nifrajs/web-react/auth.

TS
// doc-check: skip - needs `@auth/core` providers + AUTH_SECRET; install them to run this.
// backend/auth.ts - your Auth.js config (any @auth/core provider: GitHub, Google, Credentials, …):
import { authjs, getSession, requireAuthUser } from "@nifrajs/authjs"
import GitHub from "@auth/core/providers/github"
export const authConfig = {
  providers: [GitHub({ clientId: process.env.GITHUB_ID! })],
  secret: process.env.AUTH_SECRET!,
  trustHost: true, // or authUrl behind a proxy
}

// backend/app.ts - ONE use() mounts every Auth.js endpoint at /api/auth/*:
export const backend = server()
  .use(authjs(authConfig))                                     // sign-in, OAuth callbacks, session…
  .get("/me", async (c) => ({ user: (await getSession(c.req, authConfig))?.user ?? null }))

// routes/account.backend.ts - guard it, or read it in a loader from the raw Request:
export const loaderOutput = t.object({ user: t.object({ name: t.string() }) })
export async function loader({ request }: Route.LoaderArgs) {
  const user = await requireAuthUser(request, authConfig, { redirectTo: "/login" })
  return { user }
}

// Frontend - any framework via the agnostic client, or React bindings:
// import { createAuthClient } from "@nifrajs/authjs/client"
// await createAuthClient().signIn("github")
// import { AuthSessionProvider, useAuthSession } from "@nifrajs/web-react/auth"

Secrets resolve per request - explicit secret, then the AUTH_SECRET platform binding (edge-safe), then process.env - and a missing secret fails loud instead of signing with nothing. Auth.js builds sign-in links, redirects and cookies from the request's origin, so in production it trusts the Host header only when told to: pass authUrl (or set AUTH_URL), which every request is rewritten onto, or set trustHost when a proxy you control fixes the host. Give getSession and requireAuthUser the same authUrl, trustProxy and basePath as the mount.

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.

The signature covers the cookie name, so two managers that share a secret never read each other's cookies; still, give sessions a secret no other signed value uses. A cookie-mode session lives at most maxAge, whatever expiry its payload names. Upgrading from 4.x, whose cookies were signed without the name, signs everyone out unless you pass acceptLegacyCookies: true: keep it for one maxAge after the upgrade (each such session is re-signed on its next commit), then remove it, since while it is on any session-shaped value the app signs with the same secret through signValue reads as a session.

TS
// backend/auth.ts - one session manager, created on first use so the secret is read at request time.
// 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
// backend/app.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")
})

Guard a route

requireSession / requireUser throw a status(...) render (a 302 to redirectTo, or a 401) when the session is missing - Nifra renders a thrown control-flow value as-is, so the guard short-circuits the loader. It is plain data, not a Response: same bytes on the wire, on the lane an ordinary return takes. To guard every page under a directory, run the same check in the middleware export of that directory's _layout.backend.ts - see Route middleware.

TS
// routes/account.backend.ts - a protected route's loader reads the session and redirects when absent.
import { requireUser } from "@nifrajs/auth"
import { t } from "@nifrajs/schema"
import { getSessions } from "../backend/auth"   // backend code: a backend half imports it directly
import type { Route } from "./+types/account"

export const loaderOutput = t.object({ userId: t.string() })
export async function loader({ request }: Route.LoaderArgs) {
  const session = await getSessions().read(request)
  // requireUser throws a 302 to /login when there's no session, and the loader stops there.
  const userId = requireUser(session, "userId", { redirectTo: "/login" })
  return { userId }
}

Without redirectTo, a guard denies with a 401 JSON { ok: false, error: "unauthorized" }. unauthenticated sets that response's status (400-599), body and headers instead; the same option exists on @nifrajs/better-auth's requireSession, requirePrincipal and authed, and on @nifrajs/authjs's requireAuthUser. A guard given both redirectTo and unauthenticated throws.

TS
// doc-check: skip - fragment: `session` is the request's session.
requireUser(session, "userId", {
  unauthenticated: { status: 403, body: { error: "login_required" }, headers: { "www-authenticate": "Session" } },
})

One request-scoped context from authentication to the handler

around wraps everything after routing for the routes declared after it: authentication, body validation, derive and the handler. An AsyncLocalStorage frame or a database session setting opened there stays open until the response is decided. authenticate runs inside that frame before validation, resolves the caller once and types it as c.principal, so it can write what it resolved into the frame instead of a second lookup in a derive. Both are order-scoped, so inside a group they cover only its routes.

TS
// doc-check: skip - fragment: `app`, `readSession`, `Note`, and `db` are your application's.
import { AsyncLocalStorage } from "node:async_hooks"
import { authenticated, rejected } from "@nifrajs/core"

const frame = new AsyncLocalStorage<{ userId?: string }>()

app.group("/api", (api) =>
  api
    // Opens before authentication and closes after the response: validation, derive and the
    // handler all run inside it, and so does a thrown Response.
    .around((_c, next) => frame.run({}, next))
    // Resolves the caller once: typed as c.principal, and written into the open frame.
    .authenticate({
      id: "session",
      run: async (input) => {
        const session = await readSession(input.headers.get("cookie"))
        if (session === null) return rejected()
        const store = frame.getStore()
        if (store !== undefined) store.userId = session.userId
        return authenticated(session)
      },
    })
    .post("/notes", { body: Note }, (c) => db.notes.insert({ ...c.body, owner: c.principal.userId })),
)

Authentication runs first

authenticate() refuses a request before anything reads it: the body is not read, no schema runs, and no derive or beforeHandle sees it. An unauthenticated caller gets the 401 (or the refusal run returns), never a 413 or a 422 that describes the route's input. basicAuth, bearer, apiKey and jwt from @nifrajs/middleware run there. With optional: true they never refuse, so validation answers as usual.

A stage that throws, or returns anything but authenticated(principal) or rejected(...), fails closed with 503 { ok: false, error: "auth_unavailable" }, keeping 401 and 403 for the two real decisions and the cause out of the response. The cause goes where a handler's error goes: to onError, whose response replaces the 503, and when no hook answers, to the logger as unhandled request error. A database outage inside authed() is reported the same way. A thrown Response or status(...) is sent as thrown, as it is from a handler.

run receives the method, pathname, headers, path params, the raw search and the parsed query (frozen, read by the parser c.query uses; a repeated key is an array), the request (never read its body), env, clientIp and an abort signal. Every field is the caller's and unvalidated. Read the query only for a short-lived, single-use ticket, such as one for a WebSocket handshake a browser cannot set a header on: a URL lands in proxy and access logs. The dev feed records a query's keys, never its values.

TS
// doc-check: skip - fragment: `app` and `tickets` (a single-use store) are your application's.
import { authenticated, rejected } from "@nifrajs/core"

app
  .authenticate({
    id: "socket-ticket",
    run: async (input) => {
      // A browser cannot set a header on a WebSocket handshake, so the ticket rides in the URL.
      const ticket = input.query.ticket
      if (typeof ticket !== "string") return rejected()
      const user = await tickets.take(ticket) // single use, expires in seconds
      return user === null ? rejected() : authenticated(user)
    },
  })
  .ws("/live", { upgrade: (c) => ({ user: c.principal.id }) })

A guard written as a beforeHandle runs after validation, so an unauthenticated caller reaches the body parser. When nifra check loads backend/app.ts (it does when a contracts.lock.json exists), NF-S008 names each route that claims authentication this way and declares an input schema. Move the check into authenticate(), or set validationOrder: "auth-before-validation" on the route to run its hooks first. A rate limiter written as a beforeHandle behind authenticate() does not see refused requests; rateLimit() runs in onRequest, before routing, and counts them.

Session code stays in the backend

The session manager, an auth instance and their secrets live in backend/, and only backend code imports them: a route's .backend.ts half, backend/app.ts. A page or a frontend/ component that imported backend/auth.ts fails the build with the import chain that reached it, so a secret cannot ride into the browser bundle. A page that needs the user reads it from its loader data, which loaderOutput narrows to the fields it declares - see Project structure.

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 per-caller buckets keyed by c.clientIp - the socket peer by default. Critical: if your app is behind a reverse proxy (CDN, load balancer), declare it with the server's clientIp option so the caller is read from the forwarding chain instead of the proxy's own address (which would put all users in one bucket). Never key on a raw X-Forwarded-For value: a client writes whatever it likes there and gets a fresh bucket per request.

TS
import { server } from "@nifrajs/core/server"
import { MemoryStore, rateLimit } from "@nifrajs/middleware"

// One reverse proxy you operate appends the caller to X-Forwarded-For. Behind a CDN that overwrites
// a single header instead, declare { header: "<that header>" }.
const app = server({ clientIp: { trustedHops: 1 } })
  .use(
    rateLimit({
      store: new MemoryStore(), // use a shared store (Redis, etc.) in production
      max: 5, // 5 attempts
      windowMs: 15 * 60 * 1000, // per 15 minutes
    }),
  )
  .post("/login", …)

Declare only hops you actually run: a directly reachable app with trustedHops set lets a client forge the forwarded address. Check your proxy's documentation for the header it sets (Cloudflare sets cf-connecting-ip; AWS ALB appends to x-forwarded-for).