Ir para o conteúdo
CodeAuditAgent
Todos os artigos

Checklist de segurança do App Router do Next.js

Checklist prático para o App Router do Next.js: server actions, route handlers, middleware, variáveis de ambiente, CSRF, cabeçalhos, webhooks e cache.

· 8 min de leitura · Lina Source LLC

O App Router trouxe muito código do navegador de volta para o servidor. Isso é, em boa medida, um ganho de segurança: consultas, segredos e regras de negócio agora rodam onde os usuários não conseguem ler. Também borrou a linha entre o que é um endpoint público e o que só parece uma chamada de função. A maior parte das falhas sérias em apps Next.js vem desse borrão.

Este checklist cobre os problemas que mais vemos em bases de código com App Router, na ordem em que vale verificá-los. Nada aqui é exótico; cada item é um ponto em que a conveniência do framework esconde uma fronteira de confiança.

1. Server actions são endpoints públicos

Uma função marcada com 'use server' compila para um endpoint HTTP. Qualquer pessoa que consiga carregar o seu site pode encontrar o ID da action e chamá-la com argumentos arbitrários, independentemente de a sua interface renderizar ou não o botão que a usa. Esconder um formulário de quem não é administrador não protege a action por trás dele.

Toda action precisa dos mesmos três passos de qualquer handler de API: autenticar quem chamou, validar a entrada e autorizar o registro específico que está sendo tocado. Coloque essas verificações dentro do corpo da própria action. Uma verificação no componente de página que renderiza o formulário roda quando a página carrega, não quando a action é chamada, então não protege nada.

'use server';

import { z } from 'zod';
import { revalidatePath } from 'next/cache';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const Input = z.object({
  projectId: z.string().uuid(),
  name: z.string().trim().min(1).max(100),
});

export async function renameProject(formData: FormData) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');

  const { projectId, name } = Input.parse({
    projectId: formData.get('projectId'),
    name: formData.get('name'),
  });

  // A verificação de propriedade é parte da escrita, não uma busca separada
  const result = await db.project.updateMany({
    where: { id: projectId, ownerId: session.user.id },
    data: { name },
  });
  if (result.count === 0) throw new Error('Not found');

  revalidatePath('/projects');
}

Fique de olho em arquivos de helpers que exportam muitas actions. Toda função exportada num módulo 'use server' é chamável, inclusive aquela que alguém acrescentou para um script interno e esqueceu.

2. Route handlers precisam das mesmas verificações

Route handlers em app/api são endpoints de forma mais evidente, mas compartilham o mesmo modo de falha: um segmento dinâmico é usado para carregar um registro sem verificar quem é o dono. Nas versões recentes do Next.js, params é uma Promise; faça await, valide e escope a consulta a quem chamou.

// app/api/documents/[id]/route.ts
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export async function GET(
  _req: Request,
  { params }: { params: Promise<{ id: string }> },
) {
  const session = await auth();
  if (!session?.user) {
    return Response.json({ error: 'unauthorized' }, { status: 401 });
  }

  const { id } = await params;
  const doc = await db.document.findFirst({
    where: { id, ownerId: session.user.id },
  });
  if (!doc) return Response.json({ error: 'not_found' }, { status: 404 });

  return Response.json(doc, {
    headers: { 'Cache-Control': 'private, no-store' },
  });
}

Devolva 404 em vez de 403 para registros que quem chamou não possui, para que o endpoint não confirme quais IDs existem. Nunca confie em params, searchParams, cabeçalhos ou cookies para decisões de autorização; tudo isso é entrada controlada pelo atacante.

3. Middleware não é, sozinho, uma fronteira de autenticação

O middleware (renomeado para proxy no Next.js 16) é útil para redirecionar quem não está autenticado e para definir cabeçalhos. Ele não deve ser o único lugar onde a autorização acontece. Matchers são fáceis de errar, rotas novas são criadas fora deles, e server actions fazem POST para o caminho da página, que pode não casar com o padrão que você tinha em mente. Em 2025, a CVE-2025-29927 mostrou que um cabeçalho interno forjado podia fazer algumas versões do Next.js pularem o middleware por completo.

Trate o middleware como uma camada de conveniência. A verificação de verdade pertence ao lado dos dados: em cada action e handler ou, melhor ainda, numa camada de acesso a dados pela qual toda leitura no servidor passa. Uma camada de acesso a dados é um único módulo server-only que expõe funções como getProjectForUser(projectId) e faz a verificação de sessão e o filtro de propriedade internamente. Páginas, actions e route handlers a chamam em vez de chamar o cliente do banco diretamente, então uma rota nova não consegue esquecer a verificação: não existe caminho sem verificação para esquecer.

4. Mantenha o código de servidor no servidor

Qualquer variável de ambiente com o prefixo NEXT_PUBLIC_ é embutida no bundle JavaScript no momento do build e fica visível para todos os visitantes. Isso é correto para uma chave publicável do Stripe ou um ID de analytics, e é um vazamento para qualquer outra coisa. Procure NEXT_PUBLIC_ na base de código e verifique se cada uma delas poderia estar num outdoor sem problema. O erro inverso também acontece: uma variável sem o prefixo é lida num client component, volta undefined no navegador, e alguém a renomeia para NEXT_PUBLIC_ para o erro sumir. Se um valor é necessário no navegador, pergunte primeiro se o navegador deveria tê-lo.

// lib/dal.ts
import 'server-only';
import { cache } from 'react';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

export const getCurrentUser = cache(async () => {
  const session = await auth();
  if (!session?.user) return null;
  // Devolva só os campos de que a UI precisa, nunca a linha inteira
  return db.user.findUnique({
    where: { id: session.user.id },
    select: { id: true, name: true, plan: true },
  });
});

O pacote server-only faz o build falhar se um client component importar o módulo, o que protege clientes de banco e helpers que leem segredos de acabarem no navegador. Fique de olho também no que os server components passam como props para client components: tudo o que cruza essa fronteira é serializado na página, então passar a linha completa de um usuário envia ao navegador o hash da senha e as flags internas.

5. CSRF: saiba o que o framework cobre

Server actions só aceitam POST, e o Next.js compara o cabeçalho Origin com o host antes de executá-las. Se você implanta atrás de um proxy ou em vários domínios, configure serverActions.allowedOrigins de forma deliberada, em vez de ir alargando até os erros sumirem.

Route handlers não ganham essa proteção. Se um handler POST, PUT ou DELETE autentica por cookies, um formulário em outro site ainda pode enviar dados para ele. Verificar o content type não basta sozinho: um formulário HTML pode enviar corpos urlencoded, multipart e text/plain sem preflight, e um handler que interpreta o corpo de forma permissiva vai aceitá-los. Defina cookies de sessão com SameSite=Lax ou Strict, nunca faça mudanças de estado em GET, e verifique o cabeçalho Origin em mutações autenticadas por cookie. Handlers que só aceitam um bearer token no cabeçalho Authorization não estão expostos ao CSRF clássico.

6. Cabeçalhos de segurança e CSP

O Next.js envia pouquíssimos cabeçalhos de segurança por padrão. Adicione-os no next.config pela função headers(), ou no middleware quando você precisar de um nonce por requisição para uma Content-Security-Policy estrita.

  • Content-Security-Policy, de preferência baseada em nonce, com object-src 'none' e base-uri 'self'.
  • Strict-Transport-Security com um max-age longo, assim que o HTTPS estiver sólido em todo lugar.
  • X-Content-Type-Options: nosniff.
  • Referrer-Policy: strict-origin-when-cross-origin.
  • frame-ancestors na CSP, ou X-Frame-Options, para evitar clickjacking.
  • poweredByHeader: false no next.config, para remover o cabeçalho X-Powered-By.

7. Rate limiting

Não há rate limiter embutido. Login, cadastro, redefinição de senha, verificação de OTP e qualquer coisa que custe dinheiro por chamada (e-mail, SMS, requisições de IA) precisam de limites por usuário e por IP. Contadores em memória não funcionam em deploys serverless ou com múltiplas instâncias; use um armazenamento compartilhado como Redis ou o seu banco. Aplique os limites dentro da action ou do handler, onde o usuário autenticado é conhecido, e não só no middleware. Baseie os limites em algo que o atacante não consiga trocar barato: a conta nas rotas autenticadas, e o e-mail ou telefone de destino nos fluxos de redefinição e OTP, além do IP. Devolva 429 com um cabeçalho Retry-After para que clientes legítimos recuem corretamente.

8. Verifique webhooks contra o corpo cru

Assinaturas de webhook são calculadas sobre os bytes exatos que o provedor enviou. Interpretar o corpo como JSON e serializá-lo de novo muda esses bytes e quebra a verificação, o que tenta as pessoas a pulá-la. Num route handler, leia o corpo com req.text() e verifique antes de fazer qualquer outra coisa.

// app/api/webhooks/stripe/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);

export async function POST(req: Request) {
  const body = await req.text();
  const signature = req.headers.get('stripe-signature');
  if (!signature) return new Response('Missing signature', { status: 400 });

  let event: Stripe.Event;
  try {
    event = stripe.webhooks.constructEvent(
      body,
      signature,
      process.env.STRIPE_WEBHOOK_SECRET!,
    );
  } catch {
    return new Response('Invalid signature', { status: 400 });
  }

  // Handlers precisam ser idempotentes: provedores repetem e podem entregar duas vezes
  await handleStripeEvent(event);
  return new Response('ok');
}

Guarde os IDs dos eventos processados com uma restrição de unicidade, para que uma entrega repetida ou reenviada não conceda uma assinatura duas vezes, e trate eventos que chegam fora de ordem, já que os provedores não garantem a ordem de entrega.

9. Não faça cache global de dados por usuário

O App Router faz cache de forma agressiva, e os padrões mudaram entre versões. Uma função envolvida em unstable_cache ou 'use cache' que devolve dados do “usuário atual” mas não inclui o ID do usuário na chave de cache vai servir os dados de um usuário ao seguinte. O mesmo vale para páginas renderizadas estaticamente que deveriam ser dinâmicas, e para o cache de CDN sobre respostas de API. Teste diretamente: entre com dois usuários diferentes em dois navegadores e carregue as mesmas páginas. Qualquer coisa que mostre os dados do primeiro usuário ao segundo é um bug de cache, e normalmente um bug sério.

  • Passe o ID do usuário ou do tenant explicitamente para as funções com cache, para que ele faça parte da chave.
  • Não leia cookies nem cabeçalhos dentro de funções com cache; leia-os do lado de fora e passe os valores.
  • Envie Cache-Control: private, no-store em respostas que contenham dados por usuário.
  • Confira a saída do build: rotas que você espera que sejam dinâmicas não devem aparecer como estáticas.

Rodando o checklist

Percorra a lista por rota, não por arquivo: para cada action e handler, quem pode chamá-la, em que entrada ela confia, o que ela guarda em cache e o que ela devolve. O CodeAuditAgent pode dar uma primeira passada num repositório público do GitHub ou num trecho colado, reportando cada achado com severidade, CWE, a linha citada e uma correção sugerida; as perguntas de projeto, como quais rotas deveriam ser públicas afinal, continuam exigindo o julgamento do seu time.