Roles & Permissions
ideal-auth intentionally does not include a roles and permissions system. Access control schemas vary significantly between applications — flat roles, hierarchical RBAC, fine-grained permissions, attribute-based access control — and baking one into the library would either be too simple for real use cases or too opinionated.
Instead, ideal-auth gives you the building blocks: resolveUser fetches your user from the database (with whatever role/permission data you need), and you write the access control checks that match your schema.
This guide covers recommended patterns you can adopt.
Why it’s not built in
Section titled “Why it’s not built in”Different applications need fundamentally different models:
- A blog might need
admin/editor/viewerroles - A SaaS platform might need per-resource permissions like
invoices.create,invoices.delete - A multi-tenant system might need organization-scoped roles (
org:admin,org:member) - An enterprise system might need attribute-based policies (“users in department X can access resource Y during business hours”)
Any built-in system would either cover only the simplest case or impose a schema you would eventually fight against. By keeping roles and permissions in your application layer, you get full control over your data model and access checks.
Simple roles pattern
Section titled “Simple roles pattern”The simplest approach: each user has a single role field.
Database schema
Section titled “Database schema”CREATE TABLE users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer' -- 'admin', 'editor', 'viewer');Helper functions
Section titled “Helper functions”type Role = 'admin' | 'editor' | 'viewer';
function hasRole(user: { role: string }, role: Role): boolean { return user.role === role;}
function hasMinRole(user: { role: string }, minRole: Role): boolean { const hierarchy: Role[] = ['viewer', 'editor', 'admin']; return hierarchy.indexOf(user.role as Role) >= hierarchy.indexOf(minRole);}Usage in a route handler
Section titled “Usage in a route handler”import { createAuth } from 'ideal-auth';
const auth = createAuth({ secret: process.env.SESSION_SECRET, cookie: cookieBridge, resolveUser: (id) => db.user.findUnique({ where: { id } }), // ...});
// POST /api/admin/usersexport async function manageUsers(req, res) { const session = auth(); const user = await session.user();
if (!user) { return res.status(401).json({ error: 'Not authenticated' }); }
if (!hasRole(user, 'admin')) { return res.status(403).json({ error: 'Forbidden' }); }
// ... admin-only logic}Permission-based pattern
Section titled “Permission-based pattern”For finer-grained control, assign permissions directly to users.
Database schema
Section titled “Database schema”CREATE TABLE users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, permissions TEXT[] NOT NULL DEFAULT '{}' -- e.g., ['posts.create', 'posts.delete', 'users.manage']);Helper functions
Section titled “Helper functions”function hasPermission( user: { permissions: string[] }, permission: string,): boolean { return user.permissions.includes(permission);}
function hasAnyPermission( user: { permissions: string[] }, permissions: string[],): boolean { return permissions.some((p) => user.permissions.includes(p));}
function hasAllPermissions( user: { permissions: string[] }, permissions: string[],): boolean { return permissions.every((p) => user.permissions.includes(p));}// POST /api/postsexport async function createPost(req, res) { const session = auth(); const user = await session.user();
if (!user) { return res.status(401).json({ error: 'Not authenticated' }); }
if (!hasPermission(user, 'posts.create')) { return res.status(403).json({ error: 'Forbidden' }); }
// ... create post}Role + permission hybrid
Section titled “Role + permission hybrid”The most flexible approach: roles define groups of permissions, but access checks use permissions. This lets you add or remove capabilities from roles without changing your authorization code.
Permission mapping
Section titled “Permission mapping”const ROLE_PERMISSIONS: Record<string, string[]> = { admin: [ 'posts.create', 'posts.edit', 'posts.delete', 'posts.publish', 'users.list', 'users.manage', 'settings.manage', ], editor: [ 'posts.create', 'posts.edit', 'posts.delete', 'posts.publish', ], viewer: [ 'posts.create', ],};
function getPermissionsForRole(role: string): string[] { return ROLE_PERMISSIONS[role] ?? [];}
function userHasPermission( user: { role: string }, permission: string,): boolean { return getPermissionsForRole(user.role).includes(permission);}Middleware factory
Section titled “Middleware factory”Create a reusable middleware function that checks permissions:
function requirePermission(permission: string) { return async (req, res, next) => { const session = auth(); const user = await session.user();
if (!user) { return res.status(401).json({ error: 'Not authenticated' }); }
if (!userHasPermission(user, permission)) { return res.status(403).json({ error: 'Forbidden' }); }
req.user = user; next(); };}
// Usageapp.post('/api/posts', requirePermission('posts.create'), createPost);app.delete('/api/posts/:id', requirePermission('posts.delete'), deletePost);app.get('/api/admin/users', requirePermission('users.manage'), listUsers);Database schema patterns
Section titled “Database schema patterns”Simple: role column on users table
Section titled “Simple: role column on users table”Best for applications with a small, fixed set of roles.
CREATE TABLE users ( id TEXT PRIMARY KEY, email TEXT UNIQUE NOT NULL, password TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'viewer');Medium: roles table with join table
Section titled “Medium: roles table with join table”Best when roles are dynamic and managed through an admin interface.
CREATE TABLE roles ( id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL -- 'admin', 'editor', 'viewer');
CREATE TABLE user_roles ( user_id TEXT REFERENCES users(id) ON DELETE CASCADE, role_id TEXT REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY (user_id, role_id));// resolveUser fetches rolesasync function resolveUser(id: string) { const user = await db.user.findUnique({ where: { id }, include: { userRoles: { include: { role: true }, }, }, });
if (!user) return null;
return { ...user, roles: user.userRoles.map((ur) => ur.role.name), };}Advanced: roles + permissions + role_permissions
Section titled “Advanced: roles + permissions + role_permissions”Best for complex applications where permissions can be assigned independently of roles.
CREATE TABLE roles ( id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL);
CREATE TABLE permissions ( id TEXT PRIMARY KEY, name TEXT UNIQUE NOT NULL -- 'posts.create', 'users.manage');
CREATE TABLE role_permissions ( role_id TEXT REFERENCES roles(id) ON DELETE CASCADE, permission_id TEXT REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY (role_id, permission_id));
CREATE TABLE user_roles ( user_id TEXT REFERENCES users(id) ON DELETE CASCADE, role_id TEXT REFERENCES roles(id) ON DELETE CASCADE, PRIMARY KEY (user_id, role_id));
-- Optional: direct user-level permission overridesCREATE TABLE user_permissions ( user_id TEXT REFERENCES users(id) ON DELETE CASCADE, permission_id TEXT REFERENCES permissions(id) ON DELETE CASCADE, PRIMARY KEY (user_id, permission_id));async function resolveUser(id: string) { const user = await db.user.findUnique({ where: { id } }); if (!user) return null;
// Collect permissions from roles const rolePermissions = await db.$queryRaw` SELECT DISTINCT p.name FROM user_roles ur JOIN role_permissions rp ON rp.role_id = ur.role_id JOIN permissions p ON p.id = rp.permission_id WHERE ur.user_id = ${id} `;
// Collect direct user permissions const directPermissions = await db.$queryRaw` SELECT p.name FROM user_permissions up JOIN permissions p ON p.id = up.permission_id WHERE up.user_id = ${id} `;
const allPermissions = new Set([ ...rolePermissions.map((r) => r.name), ...directPermissions.map((r) => r.name), ]);
return { ...user, permissions: [...allPermissions], };}Framework integration examples
Section titled “Framework integration examples”Next.js middleware
Section titled “Next.js middleware”import { NextResponse } from 'next/server';import type { NextRequest } from 'next/server';
// Define which routes require which rolesconst PROTECTED_ROUTES: Record<string, string[]> = { '/admin': ['admin'], '/editor': ['admin', 'editor'],};
export async function middleware(request: NextRequest) { const { pathname } = request.nextUrl;
// Check if this route requires a role for (const [route, allowedRoles] of Object.entries(PROTECTED_ROUTES)) { if (pathname.startsWith(route)) { // Role checking must happen in a server component or API route // because middleware cannot access the database directly. // Use a lightweight check here (e.g., a role claim in the cookie) // and do the authoritative check in the server component. const hasSession = request.cookies.has('ideal_session'); if (!hasSession) { return NextResponse.redirect(new URL('/login', request.url)); } } }
return NextResponse.next();}// app/admin/page.tsx (Server Component — authoritative check)import { auth } from '@/lib/auth';import { redirect } from 'next/navigation';
export default async function AdminPage() { const session = auth(); const user = await session.user();
if (!user || user.role !== 'admin') { redirect('/'); }
return <div>Admin panel</div>;}SvelteKit hooks
Section titled “SvelteKit hooks”import { redirect } from '@sveltejs/kit';import type { Handle } from '@sveltejs/kit';import { auth } from '$lib/auth';
export const handle: Handle = async ({ event, resolve }) => { const session = auth(); const user = await session.user();
event.locals.user = user;
// Protect admin routes if (event.url.pathname.startsWith('/admin')) { if (!user || user.role !== 'admin') { throw redirect(302, '/'); } }
return resolve(event);};Server action guard pattern
Section titled “Server action guard pattern”import { auth } from './auth';
export async function requireRole(role: string) { const session = auth(); const user = await session.user();
if (!user) { throw new Error('Not authenticated'); }
if (user.role !== role) { throw new Error('Forbidden'); }
return user;}
export async function requirePermission(permission: string) { const session = auth(); const user = await session.user();
if (!user) { throw new Error('Not authenticated'); }
if (!userHasPermission(user, permission)) { throw new Error('Forbidden'); }
return user;}'use server';import { requirePermission } from '@/lib/guards';
export async function deletePost(postId: string) { const user = await requirePermission('posts.delete');
await db.post.delete({ where: { id: postId } });}Security notes
Section titled “Security notes”-
Do not store roles or permissions in the session cookie. Session cookies contain only
{ uid, iat, exp }. If you store a role in the cookie and the user’s role changes, the cookie will be stale until it expires. Always fetch the current role from the database viaresolveUser. -
Fetch fresh permissions on every request. The
resolveUserfunction is called each time you callsession.user(). This means permission changes take effect immediately — no need to force a logout or wait for the session to expire. -
Prefer permission checks over role checks. Checking
hasPermission(user, 'posts.delete')is more maintainable thanuser.role === 'admin' || user.role === 'editor'. When you add a new role, you only need to update the role-to-permissions mapping, not every authorization check. -
Fail closed. If a user’s role cannot be determined (e.g., the database query fails), deny access. Never default to allowing access on error.