Next.js SaaS
How to Implement Authentication and Role-Based Authorization in Next.js
Build GitHub sign-in, database sessions, and workspace roles with Better Auth and PostgreSQL. Protect pages, Server Actions, and API routes.
We’ll build authentication for a project dashboard: sign in with GitHub, keep the session in PostgreSQL, and let workspace owners and admins delete projects while viewers can only read them. The same permission checks will protect the page, its Server Action, and its HTTP API.
Define exactly what a signed-in user can do
Suppose Maya owns the Northstar workspace and has viewer access to Acme. Her login is the same in both places. Her permissions are different. A single user.role field cannot represent this: granting Maya owner access globally would give her control over Acme too.
Store the role on the relationship between the user and the workspace. A request is allowed only when the session is valid, that relationship is active, the role permits the operation, and the requested project belongs to that workspace. Knowing a project’s ID satisfies none of those conditions.
This implementation uses the Next.js 16 App Router, TypeScript, Better Auth 1.7, and PostgreSQL on the Node.js runtime. Start with an existing TypeScript app using the @/* import alias, a PostgreSQL database, and Node.js 24. The example implements project reads and deletion; workspace invitations and role editing need their own protected operations.
| Role | Read projects | Delete projects | Change memberships |
|---|---|---|---|
| Owner | Yes | Yes | Yes |
| Admin | Yes | Yes | No |
| Viewer | Yes | No | No |
| No active membership | No | No | No |
One protected request
Browser cookie
→ Better Auth verifies the session
→ application gets the verified user ID
→ PostgreSQL loads membership for the requested workspace
→ permission check + workspace-scoped project query
→ return only the fields the page needsConfigure a real authentication provider
Better Auth will handle the GitHub callback, session cookies, and its authentication tables. Our application will own the workspace tables and authorization decisions. Keeping these responsibilities separate lets us change a workspace role without changing the user’s identity.
Install the packages in your application and commit the resulting lockfile. Generate BETTER_AUTH_SECRET with openssl rand -base64 32. Create a GitHub OAuth app with the local callback http://localhost:3000/api/auth/callback/github; use a separate OAuth app and HTTPS callback for production.
Install dependencies
npm install better-auth@1.7.6 pg zod server-only
npm install -D @types/pgSet the server configuration
Add these values to an ignored .env file. Replace the placeholders with your database and OAuth credentials. None of these variables needs the NEXT_PUBLIC_ prefix.
.env
DATABASE_URL=postgresql://USER:PASSWORD@localhost:5432/auth_demo
BETTER_AUTH_URL=http://localhost:3000
BETTER_AUTH_SECRET=REPLACE_WITH_A_GENERATED_SECRET
GITHUB_CLIENT_ID=REPLACE_WITH_YOUR_CLIENT_ID
GITHUB_CLIENT_SECRET=REPLACE_WITH_YOUR_CLIENT_SECRETReuse one connection pool per Node process
Creating a Pool inside every request would create a new connection budget each time. This module keeps one pool, including during development reloads. With four application replicas and max: 10, the application can consume up to 40 connections; migrations and other services need capacity too.
lib/db.ts
import { Pool } from "pg";
const globalDb = globalThis as unknown as { authDemoPool?: Pool };
export const db = globalDb.authDemoPool ?? new Pool({
connectionString: process.env.DATABASE_URL,
max: 10,
connectionTimeoutMillis: 5_000,
idleTimeoutMillis: 30_000,
});
if (process.env.NODE_ENV !== "production") globalDb.authDemoPool = db;Use database sessions with a fixed expiry
We choose a seven-day absolute lifetime and disable session refresh in this example. Cookie caching stays disabled, so a session lookup checks the database. These are deliberate policies: a stolen cookie can be revoked centrally, and an active browser must sign in again after seven days.
A self-contained signed token can reduce database reads, but a role or session copied into it stays valid until expiry unless you add a revocation lookup. Database sessions fit this dashboard because role changes and lost-device logout should take effect on subsequent checks.
lib/auth.ts
import { betterAuth } from "better-auth";
import { db } from "@/lib/db";
export const auth = betterAuth({
database: db,
baseURL: process.env.BETTER_AUTH_URL!,
secret: process.env.BETTER_AUTH_SECRET!,
trustedOrigins: [process.env.BETTER_AUTH_URL!],
socialProviders: {
github: {
clientId: process.env.GITHUB_CLIENT_ID!,
clientSecret: process.env.GITHUB_CLIENT_SECRET!,
},
},
session: {
expiresIn: 60 * 60 * 24 * 7,
disableSessionRefresh: true,
cookieCache: { enabled: false },
},
});Mount the authentication endpoints
The catch-all route handles sign-in, callback, session, and sign-out requests. Generate the library’s schema after creating the configuration, review the SQL, and apply it to your development database. The CLI command is npx auth@latest generate; keep the generated migration in your application’s migration history. Use the CLI release compatible with your locked Better Auth version.
app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const runtime = "nodejs";
export const { GET, POST } = toNextJsHandler(auth);Follow the login from browser to session
The browser starts sign-in through our auth route and is redirected to GitHub. The callback returns an authorization code. Better Auth validates the OAuth flow, exchanges that code on the server, associates the provider identity with a local user, and creates a database session. The browser receives a session cookie, then returns to our site.
The GitHub access token and the application session have different jobs. The provider token is used to communicate with GitHub. Our dashboard uses the application session to identify its caller. Neither a client-supplied email address nor a GitHub username is proof of workspace membership.
Use the client below from app/sign-in/page.tsx. The callback returns to the existing home page; after creating the sample workspace in the next section, open its project URL.
lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();Sign in and handle a failed request
GitHub sign-in creates the local user on first use. A new user has no workspace access until onboarding creates a workspace or an authorized invitation grants membership.
app/sign-in/page.tsx
"use client";
import { useState } from "react";
import { authClient } from "@/lib/auth-client";
export default function SignInPage() {
const [pending, setPending] = useState(false);
const [error, setError] = useState("");
async function signIn() {
setPending(true);
setError("");
try {
const result = await authClient.signIn.social({
provider: "github",
callbackURL: "/",
});
if (result.error) {
setError("Sign-in failed. Please try again.");
setPending(false);
}
} catch {
setError("Could not reach the sign-in service.");
setPending(false);
}
}
return (
<main>
<button onClick={signIn} disabled={pending}>
{pending ? "Redirecting…" : "Continue with GitHub"}
</button>
{error && <p role="alert">{error}</p>}
</main>
);
}Store roles on workspace memberships
Apply this application migration after Better Auth’s generated migration. The auth library owns the user, account, session, and verification tables. The tables below reference its default PostgreSQL user table without adding a global role to it.
The composite membership key permits one role per user per workspace. The project’s workspace_id is mandatory. Those constraints prevent duplicate memberships and orphaned ownership; they do not perform authorization automatically. Every project query still needs the workspace boundary.
migrations/002_workspaces.sql
CREATE TABLE app_workspace (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name text NOT NULL
);
CREATE TABLE app_membership (
workspace_id uuid NOT NULL REFERENCES app_workspace(id) ON DELETE CASCADE,
user_id text NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
role text NOT NULL CHECK (role IN ('owner', 'admin', 'viewer')),
status text NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended')),
PRIMARY KEY (workspace_id, user_id)
);
CREATE TABLE app_project (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
workspace_id uuid NOT NULL REFERENCES app_workspace(id) ON DELETE CASCADE,
name text NOT NULL
);
CREATE INDEX app_project_workspace_idx ON app_project(workspace_id);
CREATE TABLE app_audit_event (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
workspace_id uuid NOT NULL REFERENCES app_workspace(id),
actor_id text NOT NULL,
operation text NOT NULL,
resource_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);Create a workspace for the local walkthrough
Sign in once, then find your local user ID in the auth database. In psql, set demo_user_id to that ID and run the statements below. This is an operator-run development seed, not an endpoint that accepts a user ID or role from a browser.
Production onboarding should create the workspace and its initial owner in one transaction, deriving the owner’s ID from the verified session. Accepting an invitation should bind the invite to its intended identity and consume a single-use, expiring token. A signed-in user must never be able to select an existing workspace and grant themselves owner access.
Development seed — run in psql after setting demo_user_id
-- First: SELECT id, email FROM "user";
-- Then: \set demo_user_id 'PASTE_YOUR_LOCAL_USER_ID'
BEGIN;
INSERT INTO app_workspace (id, name)
VALUES ('11111111-1111-4111-8111-111111111111', 'Northstar');
INSERT INTO app_membership (workspace_id, user_id, role)
VALUES ('11111111-1111-4111-8111-111111111111', :'demo_user_id', 'owner');
INSERT INTO app_project (id, workspace_id, name)
VALUES (
'22222222-2222-4222-8222-222222222222',
'11111111-1111-4111-8111-111111111111',
'Website'
);
COMMIT;
-- Open /workspaces/11111111-1111-4111-8111-111111111111/projects/22222222-2222-4222-8222-222222222222Define permissions once and deny unknown roles
Name the operation in application code: project:read or project:delete. Translate roles into those permissions in one module. The page and the server can use the same mapping, but only the server’s current membership lookup can authorize a request.
This policy returns false for an unrecognized role. That matters during migrations and when an unexpected value reaches the application. Do not interpret a missing or unknown role as the most permissive default.
lib/permissions.ts
export type Permission =
| "project:read"
| "project:delete"
| "membership:manage";
const permissions: Record<string, readonly Permission[]> = {
owner: ["project:read", "project:delete", "membership:manage"],
admin: ["project:read", "project:delete"],
viewer: ["project:read"],
};
export function can(role: string, permission: Permission): boolean {
return Object.hasOwn(permissions, role)
&& permissions[role].includes(permission);
}Where RBAC stops
The role answers whether a member may delete projects within a workspace. The project’s workspace_id answers which projects that rule applies to. These are separate conditions, and both must hold.
If the product later allows members to delete only projects they created, add a created_by check to the protected query. A role check alone cannot express that ownership rule. Likewise, a workspace admin role must not silently become permission to use an internal support console across all customers.
Put session verification inside protected data access
A layout redirect can improve navigation, but a Route Handler or Server Action can be called directly. Put the identity lookup inside the function that reads protected data. Then every caller gets the same checks.
requireUserId resolves the incoming cookie through Better Auth. It returns only the user ID and throws 401 when there is no valid session. Database or provider errors are allowed to fail the request; they must not fall back to an anonymous or previously privileged user.
The project query joins the membership for this caller to the requested workspace. A project in Acme cannot be retrieved by substituting its ID into a Northstar URL. A missing membership and a missing project both return 404 here, so the endpoint does not reveal whether another workspace’s project exists.
lib/access.ts
import "server-only";
import { headers } from "next/headers";
import { auth } from "@/lib/auth";
export class AccessError extends Error {
readonly status: 401 | 403 | 404;
constructor(status: 401 | 403 | 404) {
super(status === 401 ? "Sign in required"
: status === 403 ? "Forbidden" : "Not found");
this.status = status;
}
}
export async function requireUserId() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) throw new AccessError(401);
return session.user.id;
}Constrain the read and return a small result
Zod validates the shape of the identifiers; the membership join establishes access. A syntactically valid UUID is still untrusted. SQL placeholders keep those values separate from the query text.
Return the project’s display fields and a canDelete flag. The page does not need a session token, provider token, user row, or the full membership record. This also makes accidental serialization into a Client Component less damaging.
lib/projects.ts — read operation
import "server-only";
import { z } from "zod";
import { db } from "@/lib/db";
import { AccessError, requireUserId } from "@/lib/access";
import { can } from "@/lib/permissions";
export const projectInput = z.object({
workspaceId: z.string().uuid(),
projectId: z.string().uuid(),
});
export async function getProject(input: unknown) {
const userId = await requireUserId();
const { workspaceId, projectId } = projectInput.parse(input);
const result = await db.query<{
id: string; name: string; role: string;
}>(
`SELECT p.id, p.name, m.role
FROM app_project p
JOIN app_membership m ON m.workspace_id = p.workspace_id
WHERE p.id = $1 AND p.workspace_id = $2
AND m.user_id = $3 AND m.status = 'active'`,
[projectId, workspaceId, userId],
);
const row = result.rows[0];
if (!row || !can(row.role, "project:read")) throw new AccessError(404);
return {
id: row.id,
name: row.name,
canDelete: can(row.role, "project:delete"),
};
}Protect the page and make the UI reflect permissions
Call getProject from the Server Component. An anonymous visitor goes to sign-in; an inaccessible project renders the not-found page. The delete form appears only when the current role permits deletion.
The hidden inputs carry the target IDs, so the form is easy to wire up. They confer no authority. A viewer can add the form in browser tools or send the request themselves; the mutation in the next section must reject it.
The canDelete value describes access when this page rendered. If an owner demotes this user in another tab, the old page may still display Delete. Correctness comes from checking membership again when the write executes.
app/workspaces/[workspaceId]/projects/[projectId]/page.tsx
import { notFound, redirect } from "next/navigation";
import { ZodError } from "zod";
import { AccessError } from "@/lib/access";
import { getProject } from "@/lib/projects";
import { deleteProjectAction } from "./actions";
export const runtime = "nodejs";
export default async function ProjectPage({
params,
}: {
params: Promise<{ workspaceId: string; projectId: string }>;
}) {
const ids = await params;
const project = await getProject(ids).catch((error: unknown) => {
if (error instanceof AccessError && error.status === 401) {
redirect("/sign-in");
}
if (error instanceof AccessError || error instanceof ZodError) notFound();
throw error;
});
return (
<main>
<h1>{project.name}</h1>
{project.canDelete && (
<form action={deleteProjectAction}>
<input type="hidden" name="workspaceId" value={ids.workspaceId} />
<input type="hidden" name="projectId" value={ids.projectId} />
<button type="submit">Delete project</button>
</form>
)}
</main>
);
}Reuse the checks in an HTTP API
The API imports the same getProject and deleteProject functions. A second entry point therefore cannot accidentally omit the permission or workspace checks. Use 401 for an invalid session, 403 for an active member who lacks the operation, and 404 for an inaccessible resource.
Cookie authentication also needs CSRF protection. Better Auth protects its own endpoints; it does not wrap this custom DELETE handler. This same-origin browser API requires an Origin header exactly matching the configured application origin. Missing, null, and foreign origins are rejected before the mutation. Machine clients need a separately designed authentication path rather than an exception that weakens this cookie endpoint.
Next.js adds Origin/Host checks for Server Actions. Keep those protections enabled and configure any trusted proxy origins narrowly. CORS controls which browser scripts can read a response; it is not the permission check for a cookie-authenticated write.
app/api/workspaces/[workspaceId]/projects/[projectId]/route.ts
import { ZodError } from "zod";
import { AccessError } from "@/lib/access";
import { getProject, deleteProject } from "@/lib/projects";
export const runtime = "nodejs";
const privateHeaders = { "Cache-Control": "private, no-store" };
type Context = {
params: Promise<{ workspaceId: string; projectId: string }>;
};
function errorResponse(error: unknown) {
if (error instanceof AccessError) {
return Response.json({ error: error.message }, {
status: error.status, headers: privateHeaders,
});
}
if (error instanceof ZodError) {
return Response.json({ error: "Invalid project identifiers" }, {
status: 400, headers: privateHeaders,
});
}
// Send unexpected failures to server monitoring; do not expose SQL/errors.
return Response.json({ error: "Request failed" }, {
status: 500, headers: privateHeaders,
});
}
export async function GET(_request: Request, { params }: Context) {
try {
return Response.json(await getProject(await params), {
headers: privateHeaders,
});
} catch (error) {
return errorResponse(error);
}
}
export async function DELETE(request: Request, { params }: Context) {
const expectedOrigin = new URL(process.env.BETTER_AUTH_URL!).origin;
if (request.headers.get("origin") !== expectedOrigin) {
return Response.json({ error: "Forbidden origin" }, {
status: 403, headers: privateHeaders,
});
}
try {
await deleteProject(await params);
return new Response(null, { status: 204, headers: privateHeaders });
} catch (error) {
return errorResponse(error);
}
}Make revocation and concurrent requests predictable
A session says who is calling. Membership says what that caller can do now. Because each protected operation reads membership, demoting an admin to viewer affects the next permission check even when their seven-day session remains valid. Removing the membership blocks access to that workspace while preserving access to their other workspaces.
An already authorized request is different. If deletion takes the membership lock first, it may commit before a concurrent demotion can finish. If demotion takes the lock first, deletion waits and then checks the updated role. The guarantee is an ordered decision at the database, not cancellation of work that was already authorized.
Session revocation has a similar boundary: invalidating a session prevents later session checks from succeeding. It does not recall an HTTP response already sent, erase data in a browser, or automatically cancel an operation that passed requireUserId earlier. Financial or destructive account-wide actions may need a fresh authentication challenge and a stronger transaction boundary.
Protect the role-changing operation too
A future role-management endpoint must load the acting user’s membership, require membership:manage, and constrain the target membership to the same workspace. It must never accept an asserted actor role in JSON. Apply the same rule to invitation creation, user removal, and suspension.
Prevent removing the final owner. Two concurrent requests can each see another owner and remove both unless the check is serialized. One practical approach is to lock the workspace row FOR UPDATE, check the actor and owner count, and then change memberships in one transaction. Every path that changes ownership must take that lock in the same order.
Test denied operations with two workspaces
A successful owner login proves very little about authorization. Create Northstar and Acme in a disposable database. Give the same user admin access to Northstar and viewer access to Acme, then create one project in each. Also create a second user with no membership.
Start with an executable policy test, then test the database and HTTP paths. The critical assertion for a denial is both the response and the unchanged state: the project must still exist and no successful deletion audit event should have been added.
| Request or change | Expected result | What it proves |
|---|---|---|
| No session or a revoked session | 401 from the API; page redirects to sign-in | Every entry point verifies identity. |
| Admin in Northstar deletes a Northstar project | 204; one project removed; one audit event | The permitted path commits as a unit. |
| The same user deletes in Acme as viewer | 403; project remains | Roles are scoped to memberships. |
| Northstar URL with Acme’s project ID | 404; Acme project remains | The write uses both workspace and project IDs. |
| Non-member requests a known project ID | 404 without project fields | Knowing an ID does not grant access. |
| Demote admin after rendering the delete form | Next submission denied; project remains | The server ignores stale UI permissions. |
| DELETE with missing or foreign Origin | 403; project remains | The custom cookie endpoint checks CSRF origin. |
| Audit insert fails after the DELETE statement | Transaction rolls back; project remains | Failure cannot commit half the operation. |
tests/permissions.test.mjs — run with node --test tests/permissions.test.mjs on Node.js 24
import test from "node:test";
import assert from "node:assert/strict";
import { can } from "../lib/permissions.ts";
test("workspace roles grant only their declared permissions", () => {
const cases = [
["owner", "project:delete", true],
["owner", "membership:manage", true],
["admin", "project:delete", true],
["admin", "membership:manage", false],
["viewer", "project:read", true],
["viewer", "project:delete", false],
["unknown", "project:read", false],
["constructor", "project:read", false],
];
for (const [role, permission, expected] of cases) {
assert.equal(can(role, permission), expected, role + ": " + permission);
}
});Exercise the race instead of assuming the lock works
Use two database connections. On the first, begin a transaction and update the member’s role to viewer without committing. Start the deletion on the second connection. It should wait at SELECT … FOR SHARE. Commit the demotion; the deletion should resume, observe viewer, and return a denial.
Repeat with deletion holding the membership lock first. The demotion should wait until deletion commits. Keep the delay below the example’s three-second lock timeout. This test tells you precisely which operation wins and verifies the concurrency behavior that a permission unit test cannot cover.
Also call the Server Action directly using its captured browser request after changing the role. Testing only the visible buttons would miss the entry point an attacker can still invoke.
Operate the authentication system after launch
All replicas need the same auth secret, application URL, database, and OAuth configuration. Keep these in runtime secrets, use HTTPS, and register the exact production callback. A session created on replica A must verify on replica B. Deploy schema changes through reviewed migrations so an old and new application version can coexist during a rollout.
Rate-limit authentication traffic at a shared boundary when running multiple replicas; independent memory counters multiply the effective allowance. Configure the edge to overwrite trusted client-IP headers and prevent direct access that can spoof them. Monitor callback failures, session lookup errors, denied operations, and pool exhaustion without logging cookies or provider tokens.
This walkthrough uses GitHub login, so password verification and account recovery start with that provider. If you enable email/password later, also implement verified email delivery, expiring single-use reset tokens, attempt limits, and session revocation after sensitive credential changes. Adding a password field alone does not provide a recovery system.
For long-running exports and background jobs, store the initiating user and workspace, then define whether permission is checked again when the job executes and when its result is downloaded. A request authorized today should not produce an unrestricted download link that still works after the user leaves the workspace.
The result is a small, explicit authorization path: verified session, current membership, permitted operation, scoped query. Each additional endpoint should call that path, and each additional permission should have a denial test.