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.
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.
| Función | Leer proyectos | Eliminar proyectos | Cambiar pertenencias |
|---|---|---|---|
| Responsable | Sí | Sí | Sí |
| Admin | Sí | Sí | No |
| Lector | Sí | No | No |
| Sin pertenencia activa | 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 needsConfigura 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/pgDefine 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_SECRETReutiliza 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-222222222222Define 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>
);
}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.
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.
| Solicitud o cambio | Resultado previsto | Lo que prueba |
|---|---|---|
| Sin sesión o con una sesión revocada | 401 de la API; la página redirige al inicio de sesión | Cada punto de entrada verifica la identidad. |
| Un admin de Northstar elimina un proyecto de Northstar | 204; un proyecto eliminado; un evento de auditoría | La vía permitida se confirma como una unidad. |
| El mismo usuario elimina en Acme con rol viewer | 403; el proyecto permanece | Los roles se limitan a las pertenencias. |
| URL de Northstar con el ID de proyecto de Acme | 404; el proyecto de Acme permanece | La escritura utiliza tanto el espacio de trabajo como los IDs de proyecto. |
| Un usuario sin pertenencia solicita un ID de proyecto conocido | 404 sin campos del proyecto | Conocer un ID no da acceso. |
| Cambia el rol admin a uno inferior después de renderizar el formulario de eliminación | Siguiente envío denegado; el proyecto permanece | El servidor ignora los permisos obsoletos de la interfaz. |
| DELETE con Origin ausente o ajeno | 403; el proyecto permanece | El endpoint personalizado de cookies comprueba el origen CSRF. |
| Falla la inserción de auditoría después de la sentencia DELETE | Se revierte la transacción; el proyecto permanece | Un 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.