Adios
BlogNext.js SaaS

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.

Adios teamUpdated September 26, 202624 min read

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.

Permissions within one workspace
RoleRead projectsDelete projectsChange memberships
OwnerYesYesYes
AdminYesYesNo
ViewerYesNoNo
No active membershipNoNoNo

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 needs

Configure 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/pg

Set 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_SECRET

Reuse 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-222222222222

Define 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>
  );
}

Authorize the write in the same transaction

Deleting a project requires a fresh membership decision. Use one checked-out PostgreSQL connection for the entire transaction. Load and lock the caller’s membership, check project:delete, and delete by both project ID and workspace ID. Record the successful operation before committing.

FOR SHARE allows concurrent readers but blocks an UPDATE or DELETE of that membership row until this transaction ends. This closes the gap where a role could change after we checked it but before we performed the write. A transaction without an appropriate lock would still have that gap.

The audit insert and project deletion either commit together or roll back together. Audit actor_id comes from the verified session. We intentionally keep it as historical text so deleting an auth user later does not erase who performed an operation.

Append to lib/projects.ts

export async function deleteProject(input: unknown) {
  const userId = await requireUserId();
  const { workspaceId, projectId } = projectInput.parse(input);
  const client = await db.connect();

  try {
    await client.query("BEGIN");
    await client.query("SET LOCAL lock_timeout = '3s'");
    await client.query("SET LOCAL statement_timeout = '5s'");

    const membership = await client.query<{ role: string }>(
      `SELECT role FROM app_membership
        WHERE workspace_id = $1 AND user_id = $2 AND status = 'active'
        FOR SHARE`,
      [workspaceId, userId],
    );
    const role = membership.rows[0]?.role;
    if (!role) throw new AccessError(404);
    if (!can(role, "project:delete")) throw new AccessError(403);

    const deleted = await client.query<{ id: string }>(
      `DELETE FROM app_project
        WHERE id = $1 AND workspace_id = $2
        RETURNING id`,
      [projectId, workspaceId],
    );
    if (deleted.rowCount !== 1) throw new AccessError(404);

    await client.query(
      `INSERT INTO app_audit_event
        (workspace_id, actor_id, operation, resource_id)
        VALUES ($1, $2, 'project.delete', $3)`,
      [workspaceId, userId, projectId],
    );
    await client.query("COMMIT");
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

Call the protected operation from a Server Action

The action passes the untrusted form fields into deleteProject, which obtains identity and permissions itself. No role, owner ID, or canDelete field from the browser is accepted.

Keep redirects outside the successful mutation’s catch block because Next.js redirects throw internally. For a denied or invalid submission, this small form navigates to a fixed error page. A richer form can return typed errors through useActionState without changing the database operation.

app/workspaces/[workspaceId]/projects/[projectId]/actions.ts

"use server";

import { redirect } from "next/navigation";
import { ZodError } from "zod";
import { AccessError } from "@/lib/access";
import { deleteProject } from "@/lib/projects";

export async function deleteProjectAction(formData: FormData) {
  let destination = "/";
  try {
    await deleteProject({
      workspaceId: formData.get("workspaceId"),
      projectId: formData.get("projectId"),
    });
  } catch (error) {
    if (error instanceof AccessError && error.status === 401) {
      destination = "/sign-in";
    } else if (error instanceof AccessError || error instanceof ZodError) {
      destination = "/access-denied";
    } else {
      throw error;
    }
  }
  redirect(destination);
}

Give denied form submissions a clear destination

This page reports the failed operation without confirming that the submitted project exists.

app/access-denied/page.tsx

export default function AccessDeniedPage() {
  return <p>The project could not be changed. Check your access and try again.</p>;
}

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.

Keep authorization out of shared caches

The examples do not cache sessions, memberships, or project responses across requests. Do not put these functions inside a shared use cache or unstable_cache wrapper. Caching a successful decision by project ID alone could let the next user reuse it, and even a user-specific cached decision can outlive a revocation.

If duplicate lookups become expensive during one Server Component render, React cache can deduplicate them within that request. That has a different lifetime from a persistent Next.js data cache. Mutations should still obtain a fresh decision for their transaction. Route responses above use private, no-store; configure a CDN to respect that and exclude authenticated HTML and RSC responses from public caching.

An optional proxy.ts can redirect visitors who have no session cookie. A cookie’s presence does not prove it is valid. Keep the DAL checks even if the application also has Proxy redirects, protected layouts, and client-side route guards.

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.

Integration scenarios and expected results
Request or changeExpected resultWhat it proves
No session or a revoked session401 from the API; page redirects to sign-inEvery entry point verifies identity.
Admin in Northstar deletes a Northstar project204; one project removed; one audit eventThe permitted path commits as a unit.
The same user deletes in Acme as viewer403; project remainsRoles are scoped to memberships.
Northstar URL with Acme’s project ID404; Acme project remainsThe write uses both workspace and project IDs.
Non-member requests a known project ID404 without project fieldsKnowing an ID does not grant access.
Demote admin after rendering the delete formNext submission denied; project remainsThe server ignores stale UI permissions.
DELETE with missing or foreign Origin403; project remainsThe custom cookie endpoint checks CSRF origin.
Audit insert fails after the DELETE statementTransaction rolls back; project remainsFailure 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.

  All articles