Sub-skills

Auth in depth

For your agentCopy this and give it to the AI you're building with.
Run `cubek skill`, then `cubek skill auth`, read all of both and follow them exactly (no CLI: download https://cubek.dev/SKILL.md and https://cubek.dev/skills/auth.md verbatim with curl; don't summarize them).
Run `cubek check` after every edit until it is clean.

Sign-up, sign-in, sessions, sign-out, guarded routes, roles, API tokens and rate limits. Accounts are app code: copy this, do not invent a scheme.

Schema

// schema.ts
import { table, t } from "cubek"

export const users = table("users", {
  id: t.id(),
  email: t.string().unique({ nocase: true }),
  passwordHash: t.string(),
  role: t.enum(["member", "admin"]).default("member"),
  createdAt: t.timestamp().default("now"),
}).index("createdAt")

export const sessions = table("sessions", {
  id: t.id(),
  tokenHash: t.string().unique(),
  userId: t.ref(users),
  expiresAt: t.timestamp(),
}).index("userId")

export const apiTokens = table("apiTokens", { id: t.id(), tokenHash: t.string().unique(), userId: t.ref(users), name: t.string() })

Sessions

The cookie holds a random token; the database holds only its SHA-256, so a leaked database does not leak sessions.

// src/lib/auth.ts
import { crypto, db, encoding, type Context, type Row } from "cubek"
import type { users } from "../../schema"

export type User = Row<typeof users>
const DAYS = 30

export async function currentUser(c: Context): Promise<User | null> {
  const token = c.cookie("sid")
  if (token === null) return null
  const s = await db.sessions.findOne({ where: { tokenHash: crypto.hash("sha256", token) } })
  if (s === null || s.expiresAt < new Date().toISOString()) return null
  return await db.users.get(s.userId)
}

export async function startSession(c: Context, userId: string): Promise<void> {
  const token = encoding.hex.encode(crypto.randomBytes(32))
  const expiresAt = new Date(Date.now() + DAYS * 86400000).toISOString()
  await db.sessions.insert({ tokenHash: crypto.hash("sha256", token), userId, expiresAt })
  c.setCookie("sid", token, { httpOnly: true, sameSite: "lax", path: "/", maxAge: DAYS * 86400 })
}

export async function endSession(c: Context): Promise<void> {
  const token = c.cookie("sid")
  if (token !== null) await db.sessions.deleteWhere({ tokenHash: crypto.hash("sha256", token) })
  c.setCookie("sid", "", { maxAge: 0, path: "/" })
}

// API clients: `Authorization: Bearer <token>`, created once and shown once.
export async function tokenUser(c: Context): Promise<string | null> {
  const h = c.header("authorization")
  if (h === null || !h.startsWith("Bearer ")) return null
  const t = await db.apiTokens.findOne({ where: { tokenHash: crypto.hash("sha256", h.slice(7)) } })
  return t === null ? null : t.userId
}

Routes

// src/app.tsx
import { app, crypto, db, rateLimit, validate } from "cubek"
import { currentUser, endSession, startSession } from "./lib/auth"

const credentials = validate.object({
  email: validate.string({ email: true, max: 254 }),
  password: validate.string({ min: 8, max: 200 }),
})

// Brute force: per IP on the auth forms.
app.use("/auth/*", rateLimit({ limit: 10, window: "1m" }))

app.post("/auth/signup", async (c) => {
  const p = credentials.safeParse(await c.body())
  if (!p.success) return c.text(p.error, 400)
  if ((await db.users.findOne({ where: { email: p.data.email } })) !== null) return c.text("that email has an account", 409)
  const user = await db.users.insert({ email: p.data.email, passwordHash: crypto.password.hash(p.data.password) })
  await startSession(c, user.id)
  return c.redirect("/", 303)
})

app.post("/auth/signin", async (c) => {
  const p = credentials.safeParse(await c.body())
  if (!p.success) return c.text("wrong email or password", 401)
  const user = await db.users.findOne({ where: { email: p.data.email } })
  if (user === null || !crypto.password.verify(p.data.password, user.passwordHash)) return c.text("wrong email or password", 401)
  await startSession(c, user.id)
  return c.redirect("/", 303)
})

app.post("/auth/signout", async (c) => {
  await endSession(c)
  return c.redirect("/", 303)
})

// Guard: a middleware for a prefix (it runs for every path under it).
app.use("/admin/*", async (c, next) => {
  const u = await currentUser(c)
  if (u === null) return c.redirect("/signin", 303)
  if (u.role !== "admin") return c.text("forbidden", 403)
  await next()
})

app.get("/admin/users", async (c) => c.json(await db.users.find({ orderBy: { createdAt: "desc" }, limit: 100 })))

// Per-user limit: `key` returns the user id (null falls back to the IP).
const perUser = rateLimit({ limit: 30, window: "1h", key: async (c) => { const u = await currentUser(c); return u === null ? null : u.id } })

app.post("/api/generate", perUser, async (c) => {
  const u = await currentUser(c)
  return u === null ? c.json({ error: "sign in" }, 401) : c.json({ ok: true })
})
  • A guard on /admin/* also covers /admin/login: keep sign-in pages outside the guarded prefix, or guard route by route.
  • Handlers call currentUser(c) again (no c.set); it is one indexed read.
  • Every live query and RPC checks the user itself: anyone can call them.
  • Emails are unique ignoring case (nocase); still compare the same way.
  • Password reset: a random token (store its hash, 1 h expiry), mailed as a link with c.origin(); the link's POST sets the new hash and deletes the user's sessions (deleteWhere({ userId })).
  • CSRF: sameSite: "lax" cookies plus POST for every write cover forms; never change state in a GET.
  • Exact quotas (per day, per plan) instead of windows: kv.incr(key, 1, { ttl: "1d" }) and compare.