For the complete documentation index, see llms.txt. Prefer markdown by appending.mdto documentation URLs or sendingAccept: text/markdown.
Access control
Verify sessions on the server, protect Hono routes with middleware, and never trust client-supplied organization or user IDs for authorization.
Access control in TurboStarter has two jobs:
- Prove who the caller is (authentication)
- Prove what they are allowed to do (authorization)
Both must happen on the server. Hiding a button in React is not enough.
Authentication (sessions)
Better Auth stores sessions in cookies and validates them with BETTER_AUTH_SECRET. On the web app:
- Server Components / layouts use
getSession()from~/lib/auth/server - API routes use
enforceAuth, which callsauth.api.getSessionwith the request headers - Client code uses the Better Auth React client for sign-in flows only - never as the sole gate for sensitive actions
Protected dashboard layouts redirect when there is no user:
const { user } = await getSession();
if (!user) {
return redirect(pathsConfig.auth.login);
}Organization dashboards also verify membership before rendering tenant data - if the org is missing or the user is not a member, they redirect away.
Hardening auth
- Keep email verification enabled for password sign-up (
requireEmailVerification: trueinpackages/auth/src/server.ts) - Offer 2FA, passkeys, and session management from the account security settings
- Review
trustedOriginswhen you add mobile deep links or browser extension origins - Prefer production HTTPS so session cookies stay on secure transports
Authentication overview
Supported methods and flows.
Two-factor authentication
TOTP, OTP, and recovery codes.
API authorization
The Hono API in packages/api exposes reusable middleware in middleware.ts:
| Middleware | Purpose |
|---|---|
enforceAuth | Requires a valid session; sets c.var.user |
enforceAdmin | Requires the global admin role |
enforceUserPermission | Checks Better Auth permissions for the user |
enforceOrganizationPermission | Checks permissions inside an organization |
enforceMembership | Requires org membership at a minimum role |
Typical protected route:
export const storageRouter = new Hono().get(
"/upload",
enforceAuth,
validate("query", getObjectUrlSchema),
async (c) => c.json(await getUploadUrl(c.req.valid("query"))),
);Admin routes stack auth and role checks:
.use(enforceAuth)
.use(enforceAdmin)See Protected routes for feature/plan gating patterns.
Multi-tenant isolation
Organizations are the tenancy boundary. The critical rule:
Never authorize from a client-provided organizationId alone. Resolve the session first, then verify membership or permissions for that organization.
Billing already follows this pattern: if referenceId is not the current user, the router requires organization billing permissions before continuing:
const enforceAccessToReference = (permissions: Permissions["billing"]) =>
createMiddleware(async (c, next) => {
const referenceId =
c.req.valid("json")?.referenceId ??
c.req.query("referenceId") ??
c.var.user.id;
if (referenceId === c.var.user.id) {
return next();
}
return enforceOrganizationPermission({
organizationId: referenceId,
permissions: { billing: permissions },
})(c, next);
});When you add your own resources (projects, documents, …):
- Store
organizationId(oruserId) on the row - Authenticate the request
- Confirm membership / permission for that tenant
- Scope every query with the verified id - not the raw body field unchecked
// After enforceAuth + enforceMembership({ organizationId })
const projects = await db.query.project.findMany({
where: eq(project.organizationId, organizationId),
});Client-side hasPermission / checkRolePermission checks are for UI only. Repeat the check in the API.
Organizations RBAC
Roles, permissions, and how to extend access control.
Server Actions
If you add "use server" actions, treat them like public HTTP endpoints:
- Validate input with Zod
- Load the session inside the action
- Authorize against the resource
- Perform the mutation
Do not rely on “this action is only imported from a protected page” - clients can call Server Actions directly.
Global admin vs org admin
These are different:
- Global admin - accesses
/adminand admin API routes (enforceAdmin/ Better Auth admin plugin) - Organization admin - manages one tenant (
MemberRole.ADMIN/OWNER)
Never conflate them when writing middleware or UI.
How is this guide?
Last updated on