12k
All articles

Autorização Client-Side vs Server-Side: Por Que Você Precisa das Duas

Autorização no cliente e no servidor em React e Next.js: imponha permissões no servidor, use o cliente para UX e evite erros 403.

OpenReplay Team
OpenReplay Team
Autorização Client-Side vs Server-Side: Por Que Você Precisa das Duas

A autorização client-side é a lógica de acesso que roda no navegador e define o que os usuários veem; a autorização server-side é a lógica de acesso que roda no servidor e decide o que realmente acontece — e nenhum controle de interface muda o que o servidor aceitará. São dois trabalhos diferentes em dois limites de confiança distintos, e ignorar qualquer um deles produz uma falha específica e previsível: verificações apenas no cliente podem ser contornadas por qualquer pessoa que abra o DevTools, e verificações apenas no servidor deixam os usuários clicando em botões que retornam 403s sem entender o motivo.

Se você já publicou um app React ou Next.js onde as verificações de papel (role) ficam em blocos if (user.role === 'admin'), você já escreveu autorização client-side — talvez apenas não tenha certeza de onde fica o limite real de segurança. Este artigo traça esse limite com precisão: por que o cliente não é confiável, por que o servidor é o único guardião que importa, para que servem legitimamente as verificações client-side, e como manter as duas camadas sincronizadas usando um único identificador de permissão aplicado nos dois lados.

Principais Conclusões

  • O cliente é um ambiente não confiável: qualquer usuário pode abrir o DevTools, modificar a variável que armazena seu papel e observar suas renderizações condicionais responderem — portanto, as decisões de autorização devem ser aplicadas no servidor, em um gateway ou em uma função serverless (OWASP Authorization Cheat Sheet).
  • A autorização client-side existe para a experiência do usuário, não para segurança: ela oculta botões, protege rotas e exibe apenas os itens de menu aplicáveis, para que os usuários nunca cheguem a um beco sem saída.
  • O estado de permissões do frontend é um cache do que o servidor permitirá; quando os dois divergem, você obtém um fluxo de trabalho vulnerável a phishing (a interface mostra um botão que o servidor retorna 403) ou uma capacidade oculta (o servidor permite uma ação que a interface nunca exibe).
  • Use identificadores de permissão como tasks:delete em ambas as camadas, não comparações de nome de papel como role === 'admin' — os nomes de permissão sobrevivem a reestruturações de papéis e correspondem à unidade que o servidor realmente aplica.
  • Um 401 significa não autenticado e deve redirecionar para o login; um 403 significa autenticado, mas não autorizado, e deve exibir um estado de permissão negada (RFC 9110 §15.5.2, §15.5.4).

O cliente é um ambiente não confiável

Toda verificação de papel no seu JavaScript é executada em um ambiente que o usuário controla: ele pode abrir o DevTools, encontrar a variável que armazena seu papel, defini-la como 'admin' e observar suas renderizações condicionais responderem — a única coisa que não muda é a resposta do servidor à próxima requisição. Este é o fato fundamental que torna a autorização client-side insuficiente por si só. O navegador executa seu código, mas o usuário é dono do navegador e pode reescrever qualquer coisa entre o carregamento da página e a próxima requisição de rede.

O OWASP Authorization Cheat Sheet estabelece a regra prática diretamente: verificações de controle de acesso client-side podem melhorar a experiência do usuário, mas as decisões de autorização devem ser aplicadas no servidor, em um gateway ou em uma função serverless — porque a lógica client-side é fácil de contornar. O enquadramento conceitual é anterior aos SPAs modernos; o guia Static Apps sobre autenticação expressou assim: “o usuário final pode executar código arbitrário no lado do cliente sem permissão prévia.”

Uma Demonstração de Bypass no DevTools em 30 Segundos

A demonstração mais clara de por que a autorização somente no cliente falha é contorná-la você mesmo. Isso é reproduzível em qualquer navegador com DevTools, contra qualquer app React que armazene o papel em estado de componente em vez de derivá-lo a cada requisição a partir de um token verificado pelo servidor:

  1. Faça login como um usuário regular (não administrador). O painel de administração está oculto — seu componente renderiza {user.role === 'admin' && <AdminPanel />} e user.role é 'user'.
  2. Abra o DevTools e localize o estado do componente que armazena user. Com o React DevTools, você pode inspecionar e editar o estado de hooks diretamente; sem ele, qualquer caminho de código que exponha o objeto user a uma referência mutável funciona.
  3. Defina role como 'admin'. O React re-renderiza. O painel de administração aparece no DOM.
  4. Clique no botão de exclusão que o painel expõe. A requisição é disparada.
  5. Se o servidor aplica autorização, ele lê a identidade do seu token — não do seu estado client mutado — e retorna 403 Forbidden. Nada aconteceu com os dados.

O ataque tem sucesso no passo 3 (a interface muda) e falha no passo 5 (o servidor rejeita a requisição) somente se houver uma verificação no servidor. Sem verificação no servidor, o passo 4 muta dados reais. O bypass funciona especificamente porque o papel reside em estado client mutável; ele não funciona quando o servidor deriva novamente a autorização a partir de um token verificado em cada requisição.

O servidor é o único guardião que importa

As decisões de autorização que alteram dados ou expõem recursos protegidos devem ser tomadas e aplicadas no servidor, porque o servidor é o único participante da requisição que o usuário não pode reescrever. O backend é o guardião definitivo: independentemente do que o frontend renderiza, oculta ou desabilita, a avaliação da requisição pelo servidor é a decisão de registro. Os controles do frontend não substituem a aplicação no backend.

A seguir está a aplicação server-side de uma única permissão, tasks:delete, como um Route Handler do Next.js. A permissão é lida da sessão autenticada, não de nada que o cliente envie no corpo da requisição:

// app/api/tasks/[id]/route.ts — Next.js App Router
import { NextRequest, NextResponse } from 'next/server';
import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';

export async function DELETE(
  req: NextRequest,
  { params }: { params: { id: string } },
) {
  const permissions = await getSessionPermissions(req); // derivado de um token/sessão verificado

  if (!permissions.includes('tasks:delete')) {
    return NextResponse.json(
      { error: 'forbidden', permission: 'tasks:delete' },
      { status: 403 },
    );
  }

  await deleteTask(params.id);
  return new NextResponse(null, { status: 204 });
}

O 403 carrega o nome específico da permissão no corpo. Esse detalhe importa mais adiante: ele permite que o cliente distinga uma negação de permissão de qualquer outra falha e exiba uma mensagem coerente em vez de uma notificação genérica. O status 403 Forbidden é a escolha semântica correta conforme o RFC 9110 §15.5.4, que o define como o servidor entendendo a requisição, mas recusando-se a autorizá-la.

A Autorização Client-Side é para a Interface, não para o Controle de Acesso

A autorização client-side existe para moldar a interface: ocultar botões de administração de não-administradores, proteger rotas para que os usuários não cheguem a um 403, e exibir apenas os itens de menu aplicáveis. Ela melhora a experiência guiando os usuários em direção a ações que eles realmente podem concluir — ela não protege, e não pode proteger, nada. Pense no frontend como a vitrine, não o cofre: ele organiza o que está acessível e disponível, enquanto o cadeado permanece no servidor.

A implementação padrão lê permissões do contexto e renderiza condicionalmente com base em um identificador de permissão:

// components/TaskActions.tsx
'use client';
import { usePermissions } from '@/hooks/usePermissions';

export function TaskActions({ taskId }: { taskId: string }) {
  const { can } = usePermissions();

  return (
    <div className="task-actions">
      {can('tasks:delete') && (
        <button onClick={() => deleteTask(taskId)}>Delete</button>
      )}
    </div>
  );
}

Observe o identificador: tasks:delete, não role === 'admin'. Verificar user.role === 'admin' acopla sua interface à sua taxonomia de papéis; verificar can('tasks:delete') a acopla à ação que o servidor realmente aplica — e o servidor não se importa com qual papel a concedeu. Quando você posteriormente dividir admin em admin e billing-admin, as verificações baseadas em nomes de permissão sobrevivem intactas. O OWASP Authorization Cheat Sheet recomenda separar papéis de permissões exatamente por esse motivo. O RBAC continua sendo um modelo de controle de acesso comum para aplicações frontend; para a questão completa de seleção entre RBAC/ABAC/ACL/PBAC, a comparação de modelos de controle de acesso do LogRocket aborda bem o tema.

Uma observação técnica: replays de sessão de interfaces com controle por papel frequentemente revelam o breve momento em que um usuário não autorizado vê o botão de administração antes que uma verificação de permissão obtida do cliente resolva e o oculte — um flash de interface não autorizada causado pelo estado de permissão chegar após o primeiro paint. A solução é renderizar o estado de permissão no servidor ou pré-carregá-lo antes da renderização, o que o padrão de handoff da próxima seção faz por design.

As Duas Camadas Devem Estar de Acordo: Divergência de Permissões

A visão do frontend sobre as permissões é um cache do que o servidor permitirá, e os dois devem permanecer sincronizados. Quando o estado de permissões do frontend diverge das regras de aplicação do servidor, você obtém uma de duas falhas: um botão que o servidor retornará 403 (um fluxo de trabalho vulnerável a phishing), ou uma ação que o servidor permite, mas que a interface nunca exibe (uma capacidade oculta). A primeira é um problema de segurança e experiência do usuário — você está mostrando affordances que não funcionam, e um atacante pode estudar quais delas o servidor realmente aceita. A segunda é uma perda pura de capacidade — os usuários não conseguem acessar algo a que têm direito.

A forma de prevenir a divergência é estrutural: defina uma única fonte de verdade e alimente ambas as camadas a partir dela.

O Padrão de Handoff

O padrão de handoff é uma arquitetura de autorização na qual o servidor mantém a única fonte de verdade para permissões e o cliente mantém um cache dessa verdade — populado no login, usado para renderizar a interface, mas consultivo em vez de autoritativo. O circuito completo funciona assim: o servidor decide as permissões do usuário, envia-as ao cliente em um formato de transferência conhecido, o cliente as armazena em cache no contexto, a interface renderiza com base no cache, o usuário age, e o servidor re-verifica na requisição. O mesmo identificador de permissão aparece em cada etapa.

Comece com o formato de transferência. No login (ou na renderização inicial do Server Component), o servidor emite o conjunto de permissões:

{
  "userId": "u_8123",
  "permissions": ["tasks:read", "tasks:create", "tasks:delete"]
}

Se você carrega permissões em um JWT, elas ficam como claims no payload do token. Um JWT assinado (um JWS) carrega claims em um payload codificado em base64url que é protegido por integridade, não criptografado — a assinatura prova que as claims não foram adulteradas, mas as claims não são secretas e são legíveis pelo cliente. JWTs criptografados (JWEs) são uma construção diferente. Essa distinção vem do RFC 7519, a especificação JWT. A consequência prática: as claims de permissão de um JWT são um cache perfeitamente adequado para renderização, mas continuam sendo apenas um cache. O servidor deve validar a autorização em cada requisição usando qualquer fonte de verdade que sua arquitetura utilize.

Alimente o cache no contexto uma vez, idealmente a partir de um Server Component para que os dados estejam presentes no primeiro paint:

// app/layout.tsx — Server Component, Next.js App Router
import { getSessionPermissions } from '@/lib/auth';
import { PermissionsProvider } from '@/hooks/usePermissions';

export default async function RootLayout({
  children,
}: {
  children: React.ReactNode;
}) {
  const permissions = await getSessionPermissions(); // mesma fonte que a API usa

  return (
    <html lang="en">
      <body>
        <PermissionsProvider permissions={permissions}>
          {children}
        </PermissionsProvider>
      </body>
    </html>
  );
}
// hooks/usePermissions.tsx
'use client';
import { createContext, useContext } from 'react';

const PermissionsContext = createContext<string[]>([]);

export function PermissionsProvider({
  permissions,
  children,
}: {
  permissions: string[];
  children: React.ReactNode;
}) {
  return (
    <PermissionsContext.Provider value={permissions}>
      {children}
    </PermissionsContext.Provider>
  );
}

export function usePermissions() {
  const permissions = useContext(PermissionsContext);
  return { can: (p: string) => permissions.includes(p) };
}

Como getSessionPermissions() é a mesma função que o Route Handler chama, a interface e o controle de acesso leem dados idênticos, e tasks:delete significa a mesma coisa nos dois lados. Carregar permissões em um Server Component (em vez de buscá-las em um useEffect client-side) significa que o conjunto de permissões está disponível antes do primeiro paint — sem flash de interface incorreta, sem oscilação enquanto uma verificação resolve.

React Server Components e Server Actions: O Limite é Sintático

No App Router do Next.js, as palavras “client” e “server” descrevem onde o código é executado, não uma chamada de rede que você faz manualmente — e essa distinção é o que torna os Server Actions um lugar legítimo para aplicar autorização. Um Server Action do Next.js que chama uma verificação de permissão antes de mutar dados é autorização server-side, independentemente de onde ele esteja na árvore de componentes: o código roda no servidor, o usuário não pode modificá-lo, e a verificação não pode ser contornada editando o estado do cliente.

Na documentação atual do App Router do Next.js, a diretiva 'use server' marca uma Server Function; uma Server Function usada em um contexto de ação/mutação também é chamada de Server Action. A diretiva é o marcador de limite — o código sob ela é executado no servidor mesmo sendo escrito em um arquivo .tsx junto com componentes client:

// app/tasks/actions.ts — Next.js App Router
'use server';

import { getSessionPermissions } from '@/lib/auth';
import { deleteTask } from '@/lib/tasks';

export async function deleteTaskAction(taskId: string) {
  const permissions = await getSessionPermissions(); // roda no servidor

  if (!permissions.includes('tasks:delete')) {
    return { ok: false as const, error: 'forbidden', permission: 'tasks:delete' };
  }

  await deleteTask(taskId);
  return { ok: true as const };
}

Esta é a mesma verificação tasks:delete do Route Handler, na mesma posição de aplicação. Editar o estado do cliente para simular role === 'admin' não tem efeito aqui — a action deriva novamente as permissões da sessão no servidor. Os Server Actions não eliminam a necessidade do controle de acesso; eles são mais um lugar onde ele reside. Consulte a documentação da diretiva use server do Next.js para a semântica completa da diretiva.

Quando Seu SPA Chama uma API de Terceiros Diretamente

Quando seu SPA chama uma API de terceiros diretamente do navegador, o terceiro é seu servidor para fins de autorização — um provedor de pagamentos, um CMS headless ou um backend-as-a-service é o guardião, independentemente de você tê-lo escrito ou não. O limite de aplicação não desaparece porque você não escreveu o backend; ele se move para quem avalia a requisição. Suas verificações client-side ainda são apenas para a experiência do usuário, e as regras de autorização do terceiro são o controle de acesso. Se você não pode aplicar uma decisão em código que você controla, roteie a requisição pelo seu próprio backend para que possa.

Modos de Falha: O Que Quebra Quando Você Ignora uma Camada

Ignorar qualquer uma das camadas produz uma falha distinta e previsível. A autorização somente no cliente é trivialmente contornável; a autorização somente no servidor deixa os usuários confusos. Nenhuma das duas é aceitável por si só.

Camada ignoradaO que quebraQuem exploraExperiência do usuário
Server-side (somente client)A autorização não é aplicada de forma algumaQualquer usuário com DevTools ou curlParece normal — até que dados sejam alterados por quem não deveria
Client-side (somente server)A interface mostra ações que o servidor rejeitaráSem exploração; é um defeito de UXUsuários clicam em um botão, recebem um 403, clicam novamente, desistem

A regra é curta o suficiente para lembrar: renderize com permissões do cliente, aplique com permissões do servidor, e publique os mesmos identificadores de permissão para ambos. Viole o primeiro e os usuários clicarão em botões que retornam 403s. Viole o segundo e qualquer usuário com DevTools pode se promover.

A Experiência do Usuário em Falhas de Autorização: 403 vs 401

Quando o servidor rejeita uma ação que a interface considerava permitida, o status da resposta indica exatamente o que mostrar ao usuário. Um 401 significa que o usuário não está autenticado — redirecione para o login. Um 403 significa que ele está autenticado, mas não autorizado — exiba um estado de permissão negada, não um erro genérico, para que o usuário entenda por que a ação falhou. Essas semânticas são definidas pelo RFC 9110: §15.5.2 define 401 Unauthorized (autenticação é necessária e falhou ou não foi fornecida), e §15.5.4 define 403 Forbidden (o servidor entendeu a requisição, mas recusa-se a autorizá-la).

async function deleteTask(taskId: string) {
  const res = await fetch(`/api/tasks/${taskId}`, { method: 'DELETE' });

  if (res.status === 401) {
    window.location.assign('/login');
    return;
  }
  if (res.status === 403) {
    const body = await res.json();
    showPermissionDenied(body.permission); // ex.: "Você não tem permissão para excluir tarefas."
    return;
  }
  if (!res.ok) {
    showError('Algo deu errado. Tente novamente.');
    return;
  }
  // sucesso
}

Tratar o 403 de forma distinta é onde a divergência entre cliente e servidor se torna visível para o usuário. Replays de sessão de respostas 403 comumente mostram um usuário clicando em um botão, recebendo uma notificação de erro genérica e clicando novamente — um sinal de que a interface nunca comunicou que a ação foi negada especificamente por razões de permissão. Exibir o nome da permissão a partir do corpo da resposta (o campo permission: 'tasks:delete' que o servidor enviou) fecha essa lacuna. O RFC define o que 403 significa; como você o apresenta é sua decisão, mas o significado é o que a interface deve refletir.

Conclusão

A autorização vive em dois limites com dois trabalhos: o cliente molda a interface, o servidor decide o que acontece, e os mesmos identificadores de permissão fluem para ambos a partir de uma única fonte de verdade no servidor. Audite seu próprio código em busca dos dois modos de falha — procure verificações de papel ou permissão que protejam mutações de dados sem uma aplicação correspondente no servidor (um bypass esperando para acontecer), e procure verificações client-side que divergiram do que sua API retorna (um 403 que seus usuários não conseguem entender). Onde quer que uma verificação altere dados, confirme que o servidor deriva novamente a decisão a partir de uma sessão verificada, não de nada que o cliente possa reescrever.

Perguntas Frequentes

É seguro armazenar permissões de usuário em um JWT se o cliente pode lê-las?

Sim, desde que você trate o token como um cache e não como um limite de segurança. Um JWT assinado (um JWS) carrega claims em um payload codificado em base64url que é protegido por integridade, mas não criptografado, portanto o cliente pode ler as permissões, mas a assinatura impede adulterações. O risco não é a exposição da lista de permissões; é confiar nela. O servidor deve validar a autorização em cada requisição de acordo com o modelo de autorização utilizado pela aplicação.

Um Server Action do Next.js precisa de sua própria verificação de autorização se a página já protegeu a interface?

Sim. A proteção da interface controla apenas o que é renderizado; ela não protege a mutação. Um Server Action é executado no servidor e pode ser invocado independentemente da página que o renderizou, portanto ele deve derivar novamente as permissões da sessão verificada e rejeitar chamadas não autorizadas por conta própria. A diretiva 'use server' marca onde o código é executado, não se ele está autorizado. Trate cada Server Action como um ponto de aplicação, exatamente como um Route Handler.

Qual código de status uma API deve retornar quando um usuário está logado, mas não tem permissão para uma ação?

Retorne 403 Forbidden, não 401 Unauthorized. O RFC 9110 define 401 como autenticação sendo necessária e tendo falhado ou não sido fornecida, e 403 como o servidor entendendo a requisição, mas recusando-se a autorizá-la. Um usuário logado sem permissão está autenticado, mas não autorizado, o que é precisamente 403. O cliente deve redirecionar para o login em 401 e exibir um estado de permissão negada em 403, para que as duas falhas permaneçam visual e comportamentalmente distintas.

Por que verificar 'tasks:delete' em vez de 'role === admin' no frontend?

Identificadores de permissão sobrevivem a reestruturações de papéis; comparações de nome de papel não sobrevivem. Verificar 'role === admin' acopla a interface à sua taxonomia de papéis atual, então dividir admin em admin e billing-admin posteriormente quebra cada verificação. Verificar 'tasks:delete' acopla a interface à ação que o servidor realmente aplica, que não muda quando os papéis são reorganizados.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.