Building Authentication & Authorization with open-source...
This guide outlines the technical implementation of a multi-tenant authentication and authorization system. It focuses on the transition from basic user login to a production-ready architecture that handles organizational isolation, role-based access control (RBAC), and secure session management using modern providers like Clerk or Auth.js.
Define the Identity Provider and Data Schema
Select between a managed provider (Clerk, WorkOS) or a library-based solution (Auth.js, Lucia). For multi-tenant SaaS, ensure your data model includes an 'Organization' entity. Every user must be linked to an 'Organization' via a join table (Membership) that stores their specific role (e.g., admin, member, viewer) for that specific tenant.
⚠ Common Pitfalls
- •Hardcoding roles as a simple string on the user object, which prevents users from belonging to multiple organizations.
- •Choosing a provider that does not support multi-tenancy natively, forcing you to build complex 'org-switching' logic manually.
Configure Session Strategy and Middleware
Decide between stateless JWTs or database-backed sessions. For Next.js/SSR apps, use middleware to intercept requests and verify the session before the page renders. This prevents 'flash of unauthenticated content' and secures the edge.
import { authMiddleware } from '@clerk/nextjs';
export default authMiddleware({
publicRoutes: ['/', '/api/webhooks/clerk'],
afterAuth(auth, req) {
if (!auth.userId && !auth.isPublicRoute) {
return redirectToSignIn({ returnBackUrl: req.url });
}
if (auth.userId && !auth.orgId && req.nextUrl.pathname !== '/select-org') {
return NextResponse.redirect(new URL('/select-org', req.url));
}
}
});⚠ Common Pitfalls
- •Failing to check the session in middleware, leading to unauthorized API access even if the UI components are hidden.
- •Mismatch between JWT expiration and the underlying session store expiration.
Implement Multi-tenant Role-Based Access Control (RBAC)
Inject organization IDs and roles into the JWT claims (custom claims). This allows your backend to verify permissions without a database lookup on every request. Create a utility function to check if a user has the required permission for a specific resource within their active organization.
export function checkPermission(session: Session, permission: 'document:write' | 'user:manage') {
const rolePermissions = {
admin: ['document:write', 'user:manage'],
member: ['document:write'],
viewer: []
};
const userRole = session.claims.org_role as keyof typeof rolePermissions;
return rolePermissions[userRole]?.includes(permission) ?? false;
}⚠ Common Pitfalls
- •Trusting the client-side role state. Always re-verify the role from the decoded JWT or database on the server.
- •Oversized JWTs: Including too many custom claims can exceed header size limits (usually 8KB).
Configure OAuth Callback and Redirect URI Safety
When integrating social logins (Google, GitHub), strictly define your callback URLs in the provider dashboard. Implement a 'state' parameter check (handled automatically by libraries like Auth.js) to prevent Cross-Site Request Forgery (CSRF). Ensure your production environment variables use HTTPS for all redirect URIs.
⚠ Common Pitfalls
- •Using wildcard redirect URIs, which can be exploited to leak authorization codes to malicious domains.
- •Forgetting to sync user metadata (like profile pictures) from the OAuth provider to your local database on the first login.
Secure API Endpoints and AI Gateways
For internal APIs, use the session cookie. For external or AI-related endpoints, implement API Key authentication. Use a library like 'unkey' or a Redis-based rate limiter to prevent a single user from exhausting your LLM credits or hitting rate limits on downstream infrastructure.
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '10 s'),
});
export async function POST(req: Request) {
const { userId } = auth();
const { success } = await ratelimit.limit(`limit_${userId}`);
if (!success) return new Response('Too Many Requests', { status: 429 });
// Proceed to AI endpoint logic
}⚠ Common Pitfalls
- •Allowing unauthenticated access to AI endpoints, leading to massive billing spikes.
- •Lack of logging for failed authentication attempts, making it impossible to debug integration issues or detect brute-force attacks.
What you built
A robust auth system requires more than just a login form; it necessitates a data-driven approach to multi-tenancy and server-side verification of every request. By leveraging middleware for protection and custom claims for RBAC, you can build a scalable foundation that secures both your UI and your API infrastructure.