Next.js SaaS
Como implementar autenticação e autorização por papéis no Next.js
Implemente login pelo GitHub, sessões no banco e papéis no espaço de trabalho com Better Auth e PostgreSQL. Proteja páginas, Server Actions e rotas de API.
Vamos implementar autenticação para um painel de projetos: login GitHub, sessão no PostgreSQL e permissão para owners e admins excluírem projetos, enquanto viewers só podem lê-los. As mesmas verificações protegem a página, sua Server Action e sua API HTTP.
Defina exatamente o que um usuário conectado pode fazer
Suponha que Maya seja owner da Northstar e tenha acesso viewer à Acme. O login é o mesmo nos dois espaços, mas as permissões são diferentes. Um único campo user.role não representa isso: conceder owner globalmente à Maya também lhe daria controle sobre a Acme.
Armazene o papel na relação entre usuário e espaço de trabalho. Uma requisição só é permitida se a sessão for válida, o vínculo estiver ativo, o papel permitir a operação e o projeto solicitado pertencer ao espaço de trabalho. Conhecer o ID de um projeto não satisfaz nenhuma dessas condições.
Esta implementação usa Next.js 16 App Router, TypeScript, Better Auth 1.7 e PostgreSQL no ambiente Node.js. Comece com uma aplicação TypeScript existente com o alias de importação @/*, um banco PostgreSQL e Node.js 24. O exemplo implementa leitura e exclusão de projetos; convites e edição de papéis exigem operações protegidas próprias.
| Função | Ler projetos | Excluir projetos | Alterar vínculos ao espaço de trabalho |
|---|---|---|---|
| Responsável | Sim | Sim | Sim |
| Administrador | Sim | Sim | Não |
| Leitor | Sim | Não | Não |
| Sem vínculo ativo | Não | Não | Não |
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 needsConfigurar um provedor de autenticação real
Better Auth cuidará do callback GitHub, dos cookies de sessão e das tabelas de autenticação. Nossa aplicação cuidará das tabelas de espaços de trabalho e das decisões de autorização. Essa separação permite mudar um papel no espaço de trabalho sem alterar a identidade do usuário.
Instale os pacotes na aplicação e registre o arquivo de lock resultante. Gere BETTER_AUTH_SECRET com openssl rand -base64 32. Crie uma aplicação OAuth GitHub com o callback local http://localhost:3000/api/auth/callback/github; use outra aplicação OAuth e um callback HTTPS para produção.
Install dependencies
npm install better-auth@1.7.6 pg zod server-only
npm install -D @types/pgDefinir a configuração do servidor
Adicione estes valores a um arquivo .env ignorado pelo controle de versão. Substitua os marcadores pelas credenciais do banco de dados e do OAuth. Nenhuma dessas variáveis precisa do prefixo 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_SECRETReutilizar um pool de conexões por processo Node
Criar um Pool a cada requisição criaria um novo limite de conexões a cada vez. Este módulo mantém um único pool, inclusive durante recargas de desenvolvimento. Com quatro réplicas da aplicação e max: 10, ela pode consumir até 40 conexões; migrações e outros serviços também precisam de capacidade.
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;Usar sessões de banco de dados com uma expiração fixa
Neste exemplo, escolhemos uma duração absoluta de sete dias e desabilitamos a renovação da sessão. O cache de cookies também fica desabilitado, então a consulta de sessão verifica o banco. São políticas deliberadas: um cookie roubado pode ser revogado centralmente, e um navegador ativo precisa entrar novamente após sete dias.
Um token assinado autossuficiente pode reduzir consultas ao banco, mas um papel ou uma sessão copiados para ele continuam válidos até expirar, a menos que você consulte a revogação. Sessões no banco são adequadas a este painel porque mudanças de papel e logout de dispositivos perdidos devem valer nas verificações seguintes.
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 },
},
});Montar os endpoints de autenticação
A rota catch-all trata requisições de login, callback, sessão e logout. Após criar a configuração, gere o esquema da biblioteca, revise o SQL e aplique-o ao banco de desenvolvimento. O comando da CLI é npx auth@latest generate; mantenha a migração gerada no histórico da aplicação. Use uma versão da CLI compatível com a versão fixa do 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);Siga o login do navegador para a sessão
O navegador inicia o login pela rota de autenticação e é redirecionado ao GitHub. O callback retorna um código de autorização. Better Auth valida o fluxo OAuth, troca o código no servidor, associa a identidade do provedor a um usuário local e cria uma sessão no banco. O navegador recebe um cookie de sessão e volta ao site.
O token de acesso GitHub e a sessão da aplicação têm funções diferentes. O token do provedor serve para comunicar com o GitHub; o painel usa a sessão da aplicação para identificar o chamador. Nem um e-mail enviado pelo cliente nem um nome de usuário GitHub comprova vínculo ao espaço de trabalho.
Use o cliente abaixo em app/sign-in/page.tsx. O callback volta à página inicial existente; após criar o espaço de trabalho de exemplo na próxima seção, abra a URL do projeto.
lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
export const authClient = createAuthClient();Entrar e tratar uma requisição com falha
O login pelo GitHub cria o usuário local no primeiro acesso. Um novo usuário não tem acesso a espaços de trabalho até que o onboarding crie um ou um convite autorizado conceda o vínculo.
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>
);
}Armazenar os papéis nos vínculos aos espaços de trabalho
Aplique esta migração da aplicação após a migração gerada pelo Better Auth. A biblioteca gerencia as tabelas de usuário, conta, sessão e verificação. As tabelas abaixo referenciam sua tabela padrão de usuários PostgreSQL sem acrescentar um papel global a ela.
A chave composta do vínculo permite um papel por usuário em cada espaço de trabalho. workspace_id é obrigatório no projeto. Essas restrições impedem vínculos duplicados e propriedade órfã, mas não autorizam operações automaticamente. Toda consulta de projeto ainda precisa do limite do espaço de trabalho.
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()
);Criar um espaço de trabalho para o exemplo local
Entre uma vez e encontre seu ID de usuário local no banco de autenticação. No psql, defina demo_user_id com esse ID e execute as instruções abaixo. Estes são dados iniciais de desenvolvimento criados por um operador, não um endpoint que aceita ID ou papel de usuário do navegador.
Em produção, o onboarding deve criar o espaço de trabalho e seu owner inicial na mesma transação, derivando o ID da sessão verificada. A aceitação de um convite deve vinculá-lo à identidade prevista e consumir um token de uso único com expiração. Um usuário conectado nunca deve poder escolher um espaço de trabalho existente e conceder a si mesmo acesso 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-222222222222Defina permissões uma vez e negue papéis desconhecidos
Nomeie a operação no código da aplicação: project:read ou project:delete. Mapeie os papéis para essas permissões em um único módulo. A página e o servidor podem usar o mesmo mapeamento, mas somente a consulta atual do vínculo no servidor pode autorizar uma requisição.
Esta política retorna false para um papel não reconhecido. Isso importa durante migrações e quando um valor inesperado chega à aplicação. Não trate um papel ausente ou desconhecido como a opção mais permissiva.
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);
}Onde o RBAC termina
O papel determina se o membro pode excluir projetos no espaço de trabalho. workspace_id determina a quais projetos a regra se aplica. São condições distintas, e ambas precisam ser atendidas.
Se o produto passar a permitir que membros excluam apenas projetos criados por eles, acrescente uma verificação created_by à consulta protegida. Verificar o papel não expressa sozinho essa regra de propriedade. Da mesma forma, ser admin de um espaço de trabalho não deve conceder silenciosamente acesso a um console interno de suporte de todos os clientes.
Colocar a verificação da sessão dentro do acesso protegido aos dados
Um redirecionamento no layout pode melhorar a navegação, mas Route Handlers e Server Actions podem ser chamados diretamente. Coloque a consulta de identidade dentro da função que lê os dados protegidos. Assim, todos os chamadores passam pelas mesmas verificações.
requireUserId resolve o cookie recebido por Better Auth. Retorna apenas o ID do usuário e lança 401 quando não há sessão válida. Erros do banco ou do provedor devem fazer a requisição falhar, sem recorrer a um usuário anônimo ou anteriormente privilegiado.
A consulta do projeto associa o vínculo do chamador ao espaço de trabalho solicitado. Um projeto da Acme não pode ser obtido substituindo seu ID em uma URL da Northstar. Tanto um vínculo ausente quanto um projeto ausente retornam 404, para não revelar a existência de projetos de outro espaço de trabalho.
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;
}Restringir a leitura e retornar um resultado reduzido
Zod valida o formato dos identificadores; a junção com os vínculos estabelece o acesso. Um UUID sintaticamente válido ainda é não confiável. Os parâmetros SQL mantêm esses valores separados do texto da consulta.
Retorne os campos de exibição do projeto e um indicador canDelete. A página não precisa de token de sessão, token de provedor, registro do usuário nem registro completo do vínculo. Isso também reduz o dano de uma serialização acidental em um 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"),
};
}Proteja a página e faça com que a interface reflita permissões
Chame getProject a partir do Server Component. Um visitante anônimo é enviado ao login; um projeto inacessível exibe a página de recurso não encontrado. O formulário de exclusão só aparece quando o papel atual permite excluir.
Os campos ocultos carregam os IDs alvo e facilitam a integração do formulário. Não concedem autorização. Um viewer pode adicionar o formulário nas ferramentas do navegador ou enviar a requisição diretamente; a alteração da próxima seção deve rejeitá-la.
canDelete descreve o acesso no momento da renderização da página. Se um owner rebaixar esse usuário em outra aba, a página antiga ainda pode mostrar Excluir. A correção depende de verificar novamente o vínculo quando a gravação for executada.
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>
);
}Reutilizar as verificações em uma API HTTP
A API importa as mesmas funções getProject e deleteProject. Assim, outro ponto de entrada não omite acidentalmente as verificações de permissão ou espaço de trabalho. Use 401 para sessão inválida, 403 para membro ativo sem a permissão da operação e 404 para recurso inacessível.
A autenticação por cookies também exige proteção CSRF. Better Auth protege seus próprios endpoints, mas não envolve este handler DELETE personalizado. Esta API de navegador da mesma origem exige um cabeçalho Origin exatamente igual à origem configurada da aplicação. Origens ausentes, null ou externas são rejeitadas antes da alteração. Clientes de máquina precisam de um caminho de autenticação próprio, em vez de uma exceção que enfraqueça este endpoint baseado em cookies.
Next.js acrescenta verificações de Origin/Host às Server Actions. Mantenha essas proteções habilitadas e restrinja as origens de proxies confiáveis. CORS controla quais scripts do navegador podem ler uma resposta; não é a verificação de permissão de uma gravação autenticada por cookie.
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);
}
}Tornar previsíveis a revogação e as requisições concorrentes
A sessão identifica quem faz a chamada. O vínculo ao espaço de trabalho determina o que essa pessoa pode fazer agora. Como cada operação protegida consulta o vínculo, rebaixar admin para viewer afeta a próxima verificação de permissão, mesmo que a sessão de sete dias continue válida. Remover o vínculo bloqueia o acesso àquele espaço de trabalho e preserva o acesso aos demais.
Uma requisição já autorizada é outro caso. Se a exclusão obtiver primeiro o bloqueio do vínculo, poderá confirmar a transação antes de um rebaixamento concorrente terminar. Se o rebaixamento obtiver o bloqueio primeiro, a exclusão espera e verifica o papel atualizado. A garantia é uma decisão ordenada no banco, não o cancelamento de trabalho já autorizado.
A revogação de sessão tem um limite semelhante: invalidar a sessão impede verificações posteriores de serem aprovadas. Isso não recupera uma resposta HTTP já enviada, não apaga dados do navegador nem cancela automaticamente uma operação aprovada antes por requireUserId. Ações financeiras ou destrutivas que afetam toda a conta podem exigir nova autenticação e um limite transacional mais forte.
Proteger também a operação que altera os papéis
Um futuro endpoint de gerenciamento de papéis deve carregar o vínculo do usuário que executa a ação, exigir membership:manage e limitar o vínculo alvo ao mesmo espaço de trabalho. Nunca deve aceitar como verdadeiro um papel informado para o autor no JSON. Aplique a mesma regra à criação de convites, remoção de usuários e suspensão.
Impeça a remoção do último owner. Duas requisições concorrentes podem identificar outro owner e remover ambos se a verificação não for serializada. Uma abordagem prática é bloquear a linha do espaço de trabalho com FOR UPDATE, verificar o autor e a quantidade de owners e alterar os vínculos em uma única transação. Todos os caminhos que mudam a propriedade devem obter esse bloqueio na mesma ordem.
Testar operações negadas com dois espaços de trabalho
Um login bem-sucedido como owner diz pouco sobre a autorização. Crie Northstar e Acme em um banco descartável. Dê ao mesmo usuário acesso admin à Northstar e viewer à Acme e crie um projeto em cada. Crie também outro usuário sem vínculo a nenhum deles.
Comece por um teste executável da política e teste depois o banco e os caminhos HTTP. Ao negar uma operação, verifique a resposta e o estado inalterado: o projeto ainda deve existir e nenhum evento de auditoria de exclusão bem-sucedida deve ter sido adicionado.
| Requisição ou alteração | Resultado esperado | O que prova |
|---|---|---|
| Nenhuma sessão ou sessão revogada | 401 da API; a página redireciona para o login | Cada ponto de entrada verifica a identidade. |
| Admin da Northstar exclui um projeto da Northstar | 204; um projeto removido; um evento de auditoria | A operação permitida é confirmada como uma unidade. |
| O mesmo usuário exclui na Acme como viewer | 403; o projeto permanece | Os papéis são definidos por vínculo ao espaço de trabalho. |
| URL da Northstar com o ID de projeto da Acme | 404; o projeto da Acme permanece | A gravação usa os IDs do espaço de trabalho e do projeto. |
| Usuário sem vínculo solicita um ID conhecido de projeto | 404 sem campos de projeto | Conhecer um ID não dá acesso. |
| Rebaixar admin após renderizar o formulário de exclusão | Próximo envio negado; o projeto permanece | O servidor ignora permissões desatualizadas da interface. |
| DELETE com Origin ausente ou externo | 403; o projeto permanece | O endpoint personalizado baseado em cookies verifica a origem para proteger contra CSRF. |
| A inserção de auditoria falha após a instrução DELETE | A transação é revertida; o projeto permanece | Uma falha não pode confirmar apenas metade da operação. |
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);
}
});Testar a condição de corrida em vez de presumir que o bloqueio funciona
Use duas conexões ao banco. Na primeira, inicie uma transação e altere o papel do membro para viewer sem confirmar. Inicie a exclusão na segunda conexão. Ela deve esperar em SELECT … FOR SHARE. Confirme o rebaixamento; a exclusão deve continuar, encontrar viewer e negar a operação.
Repita com a exclusão obtendo primeiro o bloqueio do vínculo. O rebaixamento deve esperar até a exclusão confirmar a transação. Mantenha o atraso abaixo do timeout de bloqueio de três segundos do exemplo. O teste mostra exatamente qual operação vence e verifica a concorrência que um teste unitário de permissões não cobre.
Após mudar o papel, chame também a Server Action diretamente usando a requisição capturada no navegador. Testar apenas os botões visíveis não cobre o ponto de entrada que um invasor ainda pode chamar.
Operar o sistema de autenticação após o lançamento
Todas as réplicas precisam do mesmo segredo de autenticação, URL da aplicação, banco de dados e configuração OAuth. Mantenha esses valores nos segredos de execução, use HTTPS e cadastre o callback exato de produção. Uma sessão criada na réplica A deve ser validada na B. Implante mudanças de esquema por migrações revisadas para permitir a coexistência da versão antiga e da nova durante o rollout.
Ao executar várias réplicas, limite o tráfego de autenticação em um ponto compartilhado; contadores independentes em memória multiplicam o limite efetivo. Configure a borda para sobrescrever cabeçalhos confiáveis de IP do cliente e impedir acesso direto que permita falsificá-los. Monitore falhas de callback, erros de consulta de sessão, operações negadas e esgotamento do pool sem registrar cookies ou tokens de provedores.
Este exemplo usa login GitHub, então verificação de senha e recuperação de conta começam no provedor. Se habilitar e-mail e senha depois, implemente também entrega verificada de e-mail, tokens de redefinição de uso único com expiração, limites de tentativas e revogação de sessão após mudanças sensíveis de credenciais. Adicionar apenas um campo de senha não cria um sistema de recuperação.
Para exportações demoradas e tarefas em segundo plano, armazene o usuário que as iniciou e o espaço de trabalho. Defina se a permissão será verificada novamente na execução e no download do resultado. Uma requisição autorizada hoje não deve gerar um link irrestrito que ainda funcione depois que o usuário sair do espaço de trabalho.
O resultado é um caminho pequeno e explícito de autorização: sessão verificada, vínculo atual, operação permitida e consulta com escopo. Cada novo endpoint deve usar esse caminho, e cada nova permissão deve ter um teste de negação.