For the complete documentation index, see llms.txt. Prefer markdown by appending .md to documentation URLs or sending Accept: 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:

  1. Prove who the caller is (authentication)
  2. 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 calls auth.api.getSession with 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: true in packages/auth/src/server.ts)
  • Offer 2FA, passkeys, and session management from the account security settings
  • Review trustedOrigins when you add mobile deep links or browser extension origins
  • Prefer production HTTPS so session cookies stay on secure transports

API authorization

The Hono API in packages/api exposes reusable middleware in middleware.ts:

MiddlewarePurpose
enforceAuthRequires a valid session; sets c.var.user
enforceAdminRequires the global admin role
enforceUserPermissionChecks Better Auth permissions for the user
enforceOrganizationPermissionChecks permissions inside an organization
enforceMembershipRequires org membership at a minimum role

Typical protected route:

packages/api/src/modules/storage/router.ts
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:

packages/api/src/modules/billing/router.ts
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, …):

  1. Store organizationId (or userId) on the row
  2. Authenticate the request
  3. Confirm membership / permission for that tenant
  4. Scope every query with the verified id - not the raw body field unchecked
Pattern
// 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:

  1. Validate input with Zod
  2. Load the session inside the action
  3. Authorize against the resource
  4. 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 /admin and 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

On this page

Ship your startup everywhere. In minutes.Try TurboStarter