Authentication
Sessions, JWT, OAuth, and CSRF — wired into Mandu's filling chain via middleware.
On this page
Authentication
TL;DR — Add
withSession()+withCsrf()to your filling chain, callloginUser(ctx, id)after verifying credentials, and read the user back viactx.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
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")isundefineduntil session middleware ran. If you forget.use(withSession()), the type is stillUser | undefinedbut the actual value is always undefined.- Don't read
req.cookiesdirectly. Usectx.session/ctx.get("user"). - Argon2id only. Mandu's
verifyPasswordrejects non-argon2id hashes; if you're migrating from bcrypt, hash on next login.
Related
- recipes/create-api — middleware chain basics
- reference/config —
auth.providersschema - architect/guard — what Guard catches in auth-related code
For Agents
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.
- 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`