Next.js SaaS
How to Implement Authentication and Role-Based Authorization in Next.js
Implement secure sessions, protected data access, roles, Server Action checks, Route Handler authorization, and recovery paths in Next.js.
Authentication proves who made the request. Authorization decides whether that identity may read this record or perform this mutation. A production application needs both checks at the server boundary.
Separate four responsibilities
Identity verification, session management, account recovery, and authorization fail in different ways. Model them separately. A provider can authenticate a user while your application still owns account membership, roles, suspended states, and resource-level permissions.
Prefer a maintained authentication library for credentials and provider integration. The application should still define its session duration, revocation behavior, recovery policy, and authorization model explicitly rather than accepting defaults without review.
Create and verify secure sessions
Store session identifiers or protected session data in HTTP-only, secure cookies with an appropriate same-site policy, path, and expiry. Rotate sessions after authentication-sensitive events and invalidate them on logout, password changes, or administrator revocation.
Resolve the session on the server. Return the smallest useful identity object to application code and never expose credential material to Client Components. Cache carefully: a session lookup is request-specific and must not become a shared public result.
Centralize protected reads
A data access layer should verify the session, load the caller's membership or permissions, constrain the query, and return a narrow DTO. This makes authorization part of data access rather than a convention every page author must remember.
Do not fetch a record globally and filter it after the fact. Include ownership in the query. Return not found when revealing existence would leak information, or return a clear forbidden result when the product should distinguish the states.
import "server-only";
export async function getInvoice(id: string) {
const session = await verifySession();
return db.invoice.findFirst({
where: { id, accountId: session.accountId },
select: { id: true, total: true, status: true },
});
}Check every mutation again
An exported Server Action is reachable as a server endpoint even when the interface hides its button. Verify the current session and required permission inside the action, validate all input, and scope the write to the authorized account. Apply the same rule to Route Handlers.
Treat identifiers in FormData and JSON as untrusted. A user can modify hidden fields and replay requests. Use the verified session to derive owner fields rather than accepting them from the browser.
"use server";
export async function deleteProject(projectId: string) {
const session = await verifySession();
if (!session.permissions.includes("project:delete")) {
return { error: "Forbidden" };
}
await db.project.deleteMany({
where: { id: projectId, accountId: session.accountId },
});
}Model permissions, not scattered role names
Roles are convenient bundles of permissions. Define operations such as project:read, project:delete, billing:manage, and member:invite, then map roles to them in one place. This makes exceptions visible and keeps the application from accumulating inconsistent admin checks.
Some decisions depend on the resource, not only the role. A member may edit a document they own but not another member's. Pass both the caller context and resource attributes into the authorization function and test each decision table.
Use Proxy only for optimistic checks
Proxy can redirect an obviously unauthenticated request before a protected page renders, which improves navigation. It should not be the sole authorization layer. Next.js applications have several entry points, including nested Server Components, Server Actions, and Route Handlers.
Keep Proxy fast and avoid broad database work on every asset and route. Use matchers to limit its scope, then perform secure checks in the data access and mutation paths that handle sensitive information.
Test attacks and lifecycle events
Test expired and revoked sessions, copied cookies, changed roles, disabled users, direct Server Action calls, another account's record ID, and concurrent membership removal. Verify that logs explain the denial without exposing tokens or private data.
Account recovery deserves the same rigor as login. Expire reset tokens, rate-limit attempts, invalidate old sessions after credential changes, and notify the account through a trusted channel. Test the unhappy paths before production.
Keep auth configuration outside the release
Adios secret references keep session keys, provider credentials, and database URLs out of Git while the manifest records which values the app requires. Preview the production build, verify login, logout, a protected read, a forbidden mutation, and an expired session, then inspect runtime logs for safe errors.
The health route should prove application readiness without requiring a user session or leaking auth configuration. After the release reports healthy, promotion moves the application route to the tested version while the previous healthy release remains the recovery point.
secrets:
AUTH_SECRET: secret://AUTH_SECRET
AUTH_CLIENT_ID: secret://AUTH_CLIENT_ID
AUTH_CLIENT_SECRET: secret://AUTH_CLIENT_SECRET
DATABASE_URL: secret://DATABASE_URL