LangENKO

Authentication

Sessions, JWT, OAuth, and CSRF — wired into Mandu's filling chain via middleware.

since v0.22
On this page

Authentication

TL;DR — Add withSession() + withCsrf() to your filling chain, call loginUser(ctx, id) after verifying credentials, and read the user back via ctx.get("user"). Three lines per handler.

Why

Auth is where most frameworks leak — half-finished CSRF, hand-rolled cookies, sessions stored in localStorage. Mandu ships a single recommended path: @mandujs/core/auth middleware on the filling chain, argon2id passwords, rotating session ids, and OAuth providers configured in one place.

How

Login route

// app/api/login/route.ts
import { Mandu } from "@mandujs/core";
import { verifyPassword, loginUser } from "@mandujs/core/auth";
import { withSession, withCsrf } from "@/server/lib/auth";
import { userStore } from "@/server/domain/users";

export default Mandu.filling()
  .use(withSession())
  .use(withCsrf())
  .post(async (ctx) => {
    const body = await ctx.body<{ email: string; password: string }>();
    const user = userStore.findByEmail(body.email);

    if (!user || !(await verifyPassword(body.password, user.passwordHash))) {
      return ctx.redirect("/login?error=invalid", 302);
    }

    await loginUser(ctx, user.id);
    return ctx.redirect("/dashboard", 302);
  });

Protected route

// app/api/me/route.ts
import { Mandu } from "@mandujs/core";
import { withSession } from "@/server/lib/auth";

export default Mandu.filling()
  .use(withSession())
  .guard((ctx) => {
    if (!ctx.get("user")) return ctx.unauthorized("Login required");
  })
  .get((ctx) => ctx.ok({ user: ctx.get("user") }));

OAuth (Google)

// mandu.config.ts
export default {
  auth: {
    providers: {
      google: {
        clientId: process.env.GOOGLE_CLIENT_ID!,
        clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
        scopes: ["email", "profile"],
      },
    },
  },
} satisfies ManduConfig;

Then a single callback route:

// app/api/auth/google/callback/route.ts
import { Mandu } from "@mandujs/core";
import { handleOAuthCallback, loginUser } from "@mandujs/core/auth";

export default Mandu.filling().get(async (ctx) => {
  const profile = await handleOAuthCallback(ctx, "google");
  const user = await upsertUserFromProfile(profile);
  await loginUser(ctx, user.id);
  return ctx.redirect("/dashboard", 302);
});

🤖 Agent Prompt

🤖 Agent Prompt — Wire authentication
Add authentication to my Mandu app:

1. Email/password login → create `app/api/login/route.ts` as
   `Mandu.filling().use(withSession()).use(withCsrf()).post(...)`.
   Verify with `verifyPassword(plain, hash)` from `@mandujs/core/auth`,
   then `await loginUser(ctx, user.id)` and redirect.

2. Protected routes → `.use(withSession())` then
   `.guard((ctx) => { if (!ctx.get('user')) return ctx.unauthorized(...); })`.

3. OAuth → register the provider in `mandu.config.ts` under
   `auth.providers`, then `app/api/auth/<provider>/callback/route.ts`
   calls `handleOAuthCallback(ctx, '<provider>')` and `loginUser(ctx, id)`.

Required invariants:
- withSession() must be .use()-d before any handler reading ctx.get('user')
- withCsrf() required for POST/PUT/PATCH/DELETE that mutate user data
- Never construct Set-Cookie directly; use loginUser() / logoutUser()
- Passwords use argon2id (verifyPassword), never bcrypt/sha256

After writing the route, run `bun run guard` and `bun run check`.

Pitfalls

  • CSRF on every mutating method. Even AJAX endpoints need it. Skip only for OAuth callbacks (verified via state token instead).
  • ctx.get("user") is undefined until session middleware ran. If you forget .use(withSession()), the type is still User | undefined but the actual value is always undefined.
  • Don't read req.cookies directly. Use ctx.session / ctx.get("user").
  • Argon2id only. Mandu's verifyPassword rejects non-argon2id hashes; if you're migrating from bcrypt, hash on next login.

For Agents

AI hint

Use `withSession()` and `withCsrf()` from `@mandujs/core/auth` as `.use(...)` middleware on filling chains. Use `loginUser(ctx, userId)` to start a session and `ctx.get("user")` to read it. Short-circuit unauthenticated requests with `ctx.unauthorized()` inside `.guard(...)`. Never roll your own session cookie.

Invariants
  • Session middleware (`withSession()`) must be `.use(...)`-d before any handler that reads `ctx.get("user")`
  • CSRF middleware (`withCsrf()`) is required for any `POST` / `PUT` / `PATCH` / `DELETE` that mutates user data
  • Never construct `Set-Cookie` headers directly — use `loginUser(ctx, id)` / `logoutUser(ctx)` so the framework can rotate session ids
  • Password verification uses argon2id via `verifyPassword(plain, hash)`; never bcrypt or sha256
  • For OAuth, register the provider in `mandu.config.ts` `auth.providers` and route the callback through `Mandu.filling().get(...)` at `app/api/auth/[provider]/callback/route.ts`
Guard scope
api-route