Adios
BlogNext.js SaaS

Next.js SaaS

Cómo implementar autenticación y autorización basada en roles en Next.js

Crea inicio de sesión con GitHub, sesiones de base de datos y roles de espacio de trabajo con Better Auth y PostgreSQL. Protege páginas, Server Actions y rutas de API.

Equipo de AdiosActualizado 26 de septiembre de 202624 min de lectura

Crearemos autenticación para un panel de proyectos: inicio de sesión con GitHub, sesión en PostgreSQL y permisos para que owners y admins eliminen proyectos mientras viewers solo puedan leerlos. Las mismas comprobaciones protegerán la página, su Server Action y su API HTTP.

Define exactamente qué puede hacer un usuario con sesión iniciada

Supón que Maya es propietaria de Northstar y tiene acceso viewer a Acme. Inicia sesión igual en ambos, pero sus permisos son distintos. Un único campo user.role no puede representarlo: conceder a Maya acceso owner global le daría también control sobre Acme.

Guarda el rol en la relación entre el usuario y el espacio de trabajo. Una solicitud se permite solo si la sesión es válida, la relación está activa, el rol permite la operación y el proyecto solicitado pertenece a ese espacio de trabajo. Conocer el ID de un proyecto no satisface ninguna de esas condiciones.

Esta implementación usa App Router de Next.js 16, TypeScript, Better Auth 1.7 y PostgreSQL en el entorno de ejecución Node.js. Empieza con una aplicación TypeScript existente que use el alias de importación @/*, una base de datos PostgreSQL y Node.js 24. El ejemplo implementa lectura y eliminación de proyectos; las invitaciones y la edición de roles necesitan sus propias operaciones protegidas.

Permisos en un espacio de trabajo
FunciónLeer proyectosEliminar proyectosCambiar pertenencias
ResponsableSíSíSí
AdminSíSíNo
LectorSíNoNo
Sin pertenencia activaNoNoNo

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

Configura un proveedor real de autenticación

Better Auth gestionará el callback de GitHub, las cookies de sesión y sus tablas de autenticación. Nuestra aplicación controlará las tablas del espacio de trabajo y las decisiones de autorización. Separar estas responsabilidades permite cambiar un rol del espacio de trabajo sin cambiar la identidad del usuario.

Instala los paquetes en tu aplicación y guarda el archivo de bloqueo resultante en un commit. Genera BETTER_AUTH_SECRET con openssl rand -base64 32. Crea una aplicación GitHub OAuth con el callback local http://localhost:3000/api/auth/callback/github; usa una aplicación OAuth distinta y un callback HTTPS para producción.

Install dependencies

npm install better-auth@1.7.6 pg zod server-only
npm install -D @types/pg

Define la configuración del servidor

Añade estos valores a un archivo .env excluido del control de versiones. Sustituye los marcadores por tus credenciales de base de datos y OAuth. Ninguna de estas variables necesita el prefijo NEXT_PUBLIC_.

.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

Reutiliza un pool de conexiones por proceso Node

Crear un Pool en cada solicitud generaría un nuevo límite de conexiones cada vez. Este módulo mantiene un solo pool, incluso durante las recargas de desarrollo. Con cuatro réplicas de la aplicación y max: 10, la aplicación puede consumir hasta 40 conexiones; las migraciones y otros servicios también necesitan capacidad.

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;

Usa sesiones de base de datos con caducidad fija

Elegimos una duración absoluta de siete días y desactivamos la renovación de sesión en este ejemplo. La caché de cookies sigue desactivada, por lo que consultar la sesión comprueba la base de datos. Son políticas deliberadas: una cookie robada puede revocarse centralmente y un navegador activo debe volver a iniciar sesión tras siete días.

Un token firmado autónomo puede reducir las lecturas de la base de datos, pero el rol o la sesión que contiene siguen siendo válidos hasta su caducidad, salvo que añadas una consulta de revocación. Las sesiones de base de datos encajan en este panel porque los cambios de rol y el cierre de sesión por pérdida del dispositivo deben surtir efecto en las comprobaciones posteriores.

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

Monta los endpoints de autenticación

La ruta catch-all gestiona las solicitudes de inicio de sesión, callback, sesión y cierre de sesión. Genera el esquema de la biblioteca después de crear la configuración, revisa el SQL y aplícalo a tu base de datos de desarrollo. El comando CLI es npx auth@latest generate; conserva la migración generada en el historial de migraciones de tu aplicación. Usa una versión de la CLI compatible con la versión fijada de Better Auth.

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

Sigue el inicio de sesión desde el navegador hasta la sesión

El navegador inicia sesión mediante nuestra ruta de autenticación y se redirige a GitHub. El callback devuelve un código de autorización. Better Auth valida el flujo OAuth, intercambia el código en el servidor, asocia la identidad del proveedor a un usuario local y crea una sesión de base de datos. El navegador recibe una cookie de sesión y vuelve a nuestro sitio.

El token de acceso GitHub y la sesión de la aplicación tienen funciones distintas. El token del proveedor sirve para comunicarse con GitHub. Nuestro panel usa la sesión de la aplicación para identificar al llamador. Ni una dirección de correo enviada por el cliente ni un nombre de usuario GitHub demuestran pertenencia al espacio de trabajo.

Usa el cliente siguiente desde app/sign-in/page.tsx. El callback vuelve a la página de inicio existente; después de crear el espacio de trabajo de ejemplo en la siguiente sección, abre la URL de su proyecto.

lib/auth-client.ts

import { createAuthClient } from "better-auth/react";

export const authClient = createAuthClient();

Inicia sesión y gestiona una solicitud fallida

El inicio de sesión con GitHub crea el usuario local al usarlo por primera vez. Un usuario nuevo no tiene acceso a ningún espacio de trabajo hasta que la incorporación cree uno o una invitación autorizada le conceda pertenencia.

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

Guarda los roles en las pertenencias al espacio de trabajo

Aplica esta migración de la aplicación después de la generada por Better Auth. La biblioteca de autenticación controla las tablas user, account, session y verification. Las tablas siguientes referencian su tabla PostgreSQL user predeterminada sin añadirle un rol global.

La clave compuesta de pertenencia permite un rol por usuario y espacio de trabajo. workspace_id del proyecto es obligatorio. Esas restricciones impiden pertenencias duplicadas y propiedad huérfana; no autorizan automáticamente. Cada consulta de proyecto sigue necesitando el límite del espacio de trabajo.

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()
);

Crea un espacio de trabajo para el recorrido local

Inicia sesión una vez y busca tu ID de usuario local en la base de datos de autenticación. En psql, asigna ese ID a demo_user_id y ejecuta las sentencias siguientes. Son datos iniciales de desarrollo cargados por un operador, no un endpoint que acepte un ID de usuario o un rol del navegador.

La incorporación en producción debe crear el espacio de trabajo y su propietario inicial en una sola transacción, derivando el ID del propietario de la sesión verificada. Aceptar una invitación debe vincularla a su identidad prevista y consumir un token de un solo uso con caducidad. Un usuario con sesión iniciada nunca debe poder seleccionar un espacio de trabajo existente y concederse acceso owner.

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 los permisos una vez y deniega los roles desconocidos

Nombra la operación en el código de la aplicación: project:read o project:delete. Convierte los roles en esos permisos en un módulo. La página y el servidor pueden usar la misma correspondencia, pero solo la consulta actual de pertenencia del servidor puede autorizar una solicitud.

Esta política devuelve false para un rol desconocido. Esto importa durante las migraciones y cuando llega un valor inesperado a la aplicación. No interpretes un rol ausente o desconocido como el valor predeterminado más permisivo.

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

Hasta dónde llega RBAC

El rol indica si un miembro puede eliminar proyectos en un espacio de trabajo. workspace_id del proyecto indica a qué proyectos se aplica esa regla. Son condiciones independientes y ambas deben cumplirse.

Si después permites que los miembros eliminen solo los proyectos que crearon, añade una comprobación created_by a la consulta protegida. Comprobar el rol no basta para expresar esa regla de propiedad. Del mismo modo, ser admin de un espacio de trabajo no debe convertirse silenciosamente en permiso para usar una consola interna de soporte con todos los clientes.

Coloca la verificación de sesión dentro del acceso a datos protegidos

Una redirección en el layout puede mejorar la navegación, pero se puede llamar directamente a un Route Handler o una Server Action. Coloca la consulta de identidad dentro de la función que lee los datos protegidos. Así todos los llamadores reciben las mismas comprobaciones.

requireUserId resuelve la cookie entrante mediante Better Auth. Devuelve solo el ID de usuario y lanza 401 cuando no hay sesión válida. Los errores de base de datos o proveedor deben hacer fallar la solicitud; nunca deben recurrir a un usuario anónimo o previamente privilegiado.

La consulta une la pertenencia del llamador al espacio de trabajo solicitado. No se puede obtener un proyecto de Acme sustituyendo su ID en una URL de Northstar. Tanto una pertenencia ausente como un proyecto ausente devuelven aquí 404 para que el endpoint no revele si existe un proyecto de otro espacio de trabajo.

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

Limita la lectura y devuelve un resultado pequeño

Zod valida la estructura de los identificadores; el join de pertenencia establece el acceso. Un UUID sintácticamente válido sigue sin ser fiable. Los marcadores SQL mantienen esos valores separados del texto de la consulta.

Devuelve los campos de visualización del proyecto y una bandera canDelete. La página no necesita un token de sesión o proveedor, una fila de usuario ni el registro completo de pertenencia. Esto también reduce el daño de una serialización accidental en un Client Component.

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"),
  };
}

Protege la página y haz que la UI refleje los permisos

Llama a getProject desde el Server Component. Un visitante anónimo va al inicio de sesión; un proyecto inaccesible muestra la página de recurso no encontrado. El formulario de eliminación aparece solo cuando lo permite el rol actual.

Los campos ocultos contienen los IDs de destino, lo que facilita conectar el formulario. No conceden autoridad. Un usuario viewer puede añadir el formulario con las herramientas del navegador o enviar la solicitud directamente; la mutación de la siguiente sección debe rechazarla.

canDelete describe el acceso cuando se renderizó esta página. Si un propietario reduce el rol del usuario en otra pestaña, la página anterior puede seguir mostrando Eliminar. La corrección depende de comprobar de nuevo la pertenencia cuando se ejecuta la escritura.

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

Autoriza la escritura en la misma transacción

Eliminar un proyecto requiere una nueva decisión de pertenencia. Usa una sola conexión PostgreSQL obtenida del pool durante toda la transacción. Carga y bloquea la pertenencia del llamador, comprueba project:delete y elimina usando tanto el ID del proyecto como el del espacio de trabajo. Registra la operación correcta antes de confirmar la transacción.

FOR SHARE permite lectores simultáneos, pero bloquea UPDATE o DELETE de esa fila de pertenencia hasta que termine la transacción. Esto cierra la ventana en la que un rol podría cambiar después de comprobarlo y antes de escribir. Una transacción sin el bloqueo adecuado conservaría esa ventana.

La inserción de auditoría y la eliminación del proyecto se confirman juntas o se revierten juntas. actor_id de la auditoría procede de la sesión verificada. Lo conservamos deliberadamente como texto histórico para que eliminar después un usuario de autenticación no borre quién realizó la operación.

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

Llama a la operación protegida desde una Server Action

La acción pasa los campos no fiables del formulario a deleteProject, que obtiene por sí misma la identidad y los permisos. No acepta del navegador ningún rol, ID de propietario ni campo canDelete.

Mantén las redirecciones fuera del bloque catch de la mutación correcta porque las redirecciones de Next.js lanzan excepciones internamente. Para un envío denegado o inválido, este pequeño formulario navega a una página fija de error. Un formulario más completo puede devolver errores tipados con useActionState sin cambiar la operación de base de datos.

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

Da un destino claro a los formularios rechazados

Esta página informa de la operación fallida sin confirmar que exista el proyecto enviado.

app/access-denied/page.tsx

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

Reutiliza las comprobaciones en una API HTTP

La API importa las mismas funciones getProject y deleteProject. Así, un segundo punto de entrada no puede omitir accidentalmente las comprobaciones de permisos o del espacio de trabajo. Usa 401 para una sesión inválida, 403 para un miembro activo que no tenga permiso para la operación y 404 para un recurso inaccesible.

La autenticación con cookies también necesita protección CSRF. Better Auth protege sus propios endpoints, pero no envuelve este handler DELETE personalizado. Esta API de navegador del mismo origen exige una cabecera Origin que coincida exactamente con el origen configurado de la aplicación. Los orígenes ausentes, null y ajenos se rechazan antes de la mutación. Los clientes de máquina necesitan una vía de autenticación independiente, no una excepción que debilite este endpoint de cookies.

Next.js añade comprobaciones Origin/Host a las Server Actions. Conserva esas protecciones y limita cuidadosamente los orígenes de proxy de confianza. CORS controla qué scripts de navegador pueden leer una respuesta; no es la comprobación de permisos para una escritura autenticada con cookies.

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

Haz predecibles la revocación y las solicitudes simultáneas

La sesión indica quién llama. La pertenencia indica qué puede hacer ahora. Como cada operación protegida consulta la pertenencia, cambiar el rol de admin a viewer afecta a la siguiente comprobación de permisos aunque la sesión de siete días siga siendo válida. Eliminar la pertenencia bloquea el acceso a ese espacio de trabajo y conserva el acceso a los demás.

Una solicitud ya autorizada es un caso distinto. Si la eliminación obtiene primero el bloqueo de pertenencia, puede confirmar la transacción antes de que termine una degradación de rol simultánea. Si la degradación obtiene primero el bloqueo, la eliminación espera y comprueba después el rol actualizado. La garantía es una decisión ordenada en la base de datos, no la cancelación de trabajo ya autorizado.

La revocación de sesión tiene un límite similar: invalidar una sesión impide que las comprobaciones posteriores sean correctas. No recupera una respuesta HTTP ya enviada, borra datos del navegador ni cancela automáticamente una operación que ya superó requireUserId. Las acciones financieras o destructivas de toda la cuenta pueden necesitar una nueva autenticación y un límite transaccional más fuerte.

Protege también la operación de cambio de roles

Un futuro endpoint de gestión de roles debe cargar la pertenencia del usuario que actúa, exigir membership:manage y limitar la pertenencia de destino al mismo espacio de trabajo. Nunca debe aceptar en JSON un rol declarado por el propio actor. Aplica la misma regla a la creación de invitaciones, la eliminación de usuarios y la suspensión.

Impide eliminar al último propietario. Dos solicitudes simultáneas pueden ver cada una a otro propietario y eliminar a ambos si la comprobación no se serializa. Una opción práctica es bloquear la fila del espacio de trabajo con FOR UPDATE, comprobar al actor y el número de propietarios, y cambiar después las pertenencias en una sola transacción. Todas las vías que cambien la propiedad deben obtener ese bloqueo en el mismo orden.

Mantén la autorización fuera de las cachés compartidas

Los ejemplos no comparten entre solicitudes la caché de sesiones, pertenencias ni respuestas de proyectos. No coloques estas funciones en un envoltorio compartido use cache o unstable_cache. Guardar una decisión correcta solo por ID de proyecto podría permitir que el siguiente usuario la reutilizara; incluso una decisión en caché específica del usuario puede sobrevivir a una revocación.

Si las consultas duplicadas resultan costosas durante un renderizado de Server Component, React cache puede deduplicarlas dentro de esa solicitud. Su vigencia difiere de la caché persistente de datos de Next.js. Las mutaciones deben seguir obteniendo una decisión nueva para su transacción. Las respuestas de ruta anteriores usan private, no-store; configura la CDN para respetarlo y excluir de la caché pública el HTML autenticado y las respuestas RSC.

Un proxy.ts opcional puede redirigir a los visitantes sin cookie de sesión. Que exista una cookie no demuestra que sea válida. Conserva las comprobaciones de la DAL aunque la aplicación también tenga redirecciones Proxy, layouts protegidos y controles de rutas en el cliente.

Prueba operaciones denegadas con dos espacios de trabajo

Un inicio de sesión correcto del propietario demuestra muy poco sobre la autorización. Crea Northstar y Acme en una base de datos desechable. Da al mismo usuario acceso admin en Northstar y viewer en Acme, y crea un proyecto en cada uno. Crea también un segundo usuario sin pertenencia a ninguno.

Empieza con una prueba ejecutable de la política y prueba después las vías de base de datos y HTTP. La comprobación crítica de una denegación abarca la respuesta y el estado sin cambios: el proyecto debe seguir existiendo y no debe añadirse ningún evento de auditoría de eliminación correcta.

Escenarios de integración y resultados esperados
Solicitud o cambioResultado previstoLo que prueba
Sin sesión o con una sesión revocada401 de la API; la página redirige al inicio de sesiónCada punto de entrada verifica la identidad.
Un admin de Northstar elimina un proyecto de Northstar204; un proyecto eliminado; un evento de auditoríaLa vía permitida se confirma como una unidad.
El mismo usuario elimina en Acme con rol viewer403; el proyecto permaneceLos roles se limitan a las pertenencias.
URL de Northstar con el ID de proyecto de Acme404; el proyecto de Acme permaneceLa escritura utiliza tanto el espacio de trabajo como los IDs de proyecto.
Un usuario sin pertenencia solicita un ID de proyecto conocido404 sin campos del proyectoConocer un ID no da acceso.
Cambia el rol admin a uno inferior después de renderizar el formulario de eliminaciónSiguiente envío denegado; el proyecto permaneceEl servidor ignora los permisos obsoletos de la interfaz.
DELETE con Origin ausente o ajeno403; el proyecto permaneceEl endpoint personalizado de cookies comprueba el origen CSRF.
Falla la inserción de auditoría después de la sentencia DELETESe revierte la transacción; el proyecto permaneceUn fallo no puede confirmar solo la mitad de la operación.

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

Prueba la condición de carrera en lugar de asumir que funciona el bloqueo

Usa dos conexiones de base de datos. En la primera, inicia una transacción y cambia el rol del miembro a viewer sin confirmarla. Inicia la eliminación en la segunda conexión. Debe esperar en SELECT … FOR SHARE. Confirma la degradación del rol; la eliminación debe reanudarse, observar viewer y devolver una denegación.

Repite la prueba haciendo que la eliminación obtenga primero el bloqueo de pertenencia. La degradación del rol debe esperar hasta que se confirme la eliminación. Mantén la espera por debajo del límite de tres segundos del ejemplo. Esta prueba indica exactamente qué operación gana y verifica una concurrencia que no puede cubrir una prueba unitaria de permisos.

Llama también directamente a la Server Action usando la solicitud capturada del navegador después de cambiar el rol. Probar solo los botones visibles pasaría por alto el punto de entrada que un atacante todavía puede invocar.

Opera el sistema de autenticación después del lanzamiento

Todas las réplicas necesitan el mismo secreto de autenticación, URL de aplicación, base de datos y configuración OAuth. Guárdalos como secretos del entorno de ejecución, usa HTTPS y registra el callback exacto de producción. Una sesión creada en la réplica A debe verificarse en la réplica B. Aplica los cambios de esquema mediante migraciones revisadas para que las versiones antigua y nueva puedan coexistir durante el despliegue.

Limita el tráfico de autenticación en un punto compartido cuando ejecutes varias réplicas; los contadores independientes en memoria multiplican el límite efectivo. Configura el perímetro para sobrescribir las cabeceras de IP de cliente de confianza e impide el acceso directo que pueda falsificarlas. Supervisa fallos de callback, errores de consulta de sesión, operaciones denegadas y agotamiento del pool sin registrar cookies ni tokens de proveedores.

Este recorrido usa inicio de sesión con GitHub, por lo que la verificación de contraseña y la recuperación de cuenta empiezan en ese proveedor. Si habilitas después correo y contraseña, implementa también la entrega verificada de correo, tokens de restablecimiento de un solo uso con caducidad, límites de intentos y revocación de sesiones tras cambios sensibles de credenciales. Añadir solo un campo de contraseña no proporciona un sistema de recuperación.

Para exportaciones largas y tareas en segundo plano, guarda el usuario iniciador y el espacio de trabajo. Define después si el permiso se comprueba de nuevo al ejecutar la tarea y al descargar su resultado. Una solicitud autorizada hoy no debe generar un enlace de descarga sin restricciones que siga funcionando después de que el usuario abandone el espacio de trabajo.

El resultado es una vía de autorización pequeña y explícita: sesión verificada, pertenencia actual, operación permitida y consulta limitada al ámbito. Cada endpoint adicional debe usar esa vía y cada permiso adicional debe tener una prueba de denegación.

Todos los artículos