Every app starts with two kinds of people: users, and you. So the first version of authorisation is a boolean.
if (user.email === "me@example.com") { /* admin things */ }
Then a colleague joins. Then support needs to read customer records but not
issue refunds. Then a contractor needs the analytics page for six weeks. By the
time you notice, isAdmin appears in forty components and nobody can answer
"what can a support agent actually do?" without grepping.
Here is a model that survives that growth without turning into a permissions framework you have to maintain.
Store one role per user, in a NOT NULL column
role: text("role").notNull().default("user"),
Two properties matter more than they look:
NOT NULL with a default. A nullable role means every check has to decide
what null means, and eventually one of them decides wrong. A row can never
exist in an unknown privilege state.
A string, not a boolean or an enum type. Adding "support" to a Postgres
enum is a migration with a lock; adding it to a string column is a deploy. You
lose database-level validation, which the next section replaces with something
better.
Put the vocabulary in exactly one file
// src/lib/auth/roles.ts
export const ROLES = ["user", "support", "admin"] as const;
export type Role = (typeof ROLES)[number];
const RANK: Record<Role, number> = { user: 0, support: 1, admin: 2 };
export function isRole(value: unknown): value is Role {
return typeof value === "string" && (ROLES as readonly string[]).includes(value);
}
export function hasRole(actual: unknown, required: Role): boolean {
if (!isRole(actual)) return false; // unknown fails closed
return RANK[actual] >= RANK[required];
}
The ranking is what stops the OR lists. Without it, every check that admins
should also pass becomes role === "support" || role === "admin", and the day
you add "owner" you must find all of them. With it, hasRole(role, "support")
is true for an admin forever.
hasRole takes unknown deliberately. Roles arrive from a database column that
was added after some rows existed, from a session cache that may predate a
config change, and from JSON. An unrecognised value must be least-privileged,
never most.
Rank works until it does not
Ranking assumes privileges nest: everything support can do, an admin can do. That is true for the overwhelming majority of internal tools, and it is worth staying inside it for as long as you can.
The moment it stops being true (a "billing" role that can issue refunds but must not read support tickets, a "read-only auditor" who outranks support in one dimension and not another) add a capability map beside the ranks rather than inventing more roles:
const CAPABILITIES: Record<Role, readonly Capability[]> = {
user: [],
support: ["ticket:read", "ticket:reply", "user:read"],
admin: ["ticket:read", "ticket:reply", "user:read", "user:write", "refund:issue"],
};
export function can(role: Role, capability: Capability): boolean {
return CAPABILITIES[role].includes(capability);
}
Now the call site says what it needs (can(user.role, "refund:issue")) rather
than which title happens to have it today. Reading the table answers "what can
support do?" in one screen, which is the question an auditor, a new hire and
your future self all ask.
Do not start here. A capability map with three roles and two capabilities is ceremony. Move to it the first time a role does not fit the ladder.
Never let a user write their own role
The sign-up path must not accept a role, from a form field, a query parameter, an invite payload or an OAuth profile. The column default is the only thing that sets it. Promotion happens through an admin-only path that checks the actor's role first:
"use server";
export async function setRole(targetUserId: string, role: Role) {
await requireRole("admin"); // the actor, from the session
if (!isRole(role)) throw new Error("Unknown role");
// ...
}
Two failure modes hide here. Trusting role from the body without isRole
writes an arbitrary string that every ranked check then fails closed on:
confusing, but safe. Forgetting requireRole lets anyone promote themselves,
not safe at all, and it looks completely normal in review because the function
name says "admin".
Sessions cache the role
Better Auth's cookie cache keeps a signed snapshot of the user for a few minutes, so most requests answer without a database read. A role changed with raw SQL is therefore invisible for up to that long. Two consequences:
- Demotion is not immediate. If a demotion must take effect now (a compromised account, a departure) revoke that user's sessions as well as changing the row.
- Promote through the API rather than SQL where you can, so the cache is refreshed as part of the change.
The admin panel side
The panel's route group checks the role once, in its layout, and pages inside it
do not repeat it. That is fine for rendering. It is not enough for actions: a
server action called from an admin page is a public endpoint, so it calls
requireRole itself. The layout controls what is shown; the action controls
what is done.
Checking your work
grep -rn "=== \"admin\"" src/returns nothing outsideroles.ts.- Every server action that mutates something privileged has a
requireRoleon its first line. - Setting a user's role to
"wizard"by hand denies everything rather than granting everything. - Demote yourself in the database, wait for the cookie cache to expire, and
confirm
/adminbecomes a 404.