Guides

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.

4-6 hours5 steps
1

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.
2

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.

middleware.ts
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.
3

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.

lib/permissions.ts
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).
4

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.
5

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.

app/api/ai/route.ts
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.