Alternar tema

Segurança e autenticação de APIs no Next.js: guia prático completo de JWT a rate limiting

Easton editorial illustration: cache waterfall instrument

O celular vibrou: era um alerta de cobrança do provedor de nuvem. US$ 7.800.

Esfreguei os olhos, achando que tinha lido errado. No mês anterior, a conta havia sido de apenas US$ 120. Abri os detalhes: 18 milhões de chamadas à API. Em um dia normal, meu projeto pessoal recebia apenas algumas centenas.

Um crawler havia encontrado um endpoint da minha API sem nenhuma proteção e o bombardeou durante três dias inteiros. Foi naquele momento que entendi por que tanta gente diz que segurança de API não é opcional.

Este artigo organiza de forma sistemática os erros em que caí e as soluções que pesquisei nos últimos anos. De autenticação JWT e configuração de CORS a rate limiting e validação de entrada: nada de teoria vazia, mas código prático que você pode usar diretamente no projeto.

Por que a segurança de APIs é tão importante

Ameaças comuns à segurança de APIs

10.0
Pontuação CVSS
Pontuação da vulnerabilidade crítica de dezembro de 2025 no React Server Components (CVE-2025-55182), que permite a execução arbitrária de código

Em dezembro do ano passado, a equipe oficial do React publicou um alerta de segurança crítico, identificado como CVE-2025-55182. A pontuação CVSS chegou ao máximo: 10.0.

O que isso significa? Nota máxima. Bastava o invasor construir uma requisição HTTP especial para executar qualquer código no seu servidor. Se você usava React Server Components e não atualizou a tempo, estava praticamente sem proteção.

E esse não foi um caso isolado. Em março deste ano, surgiu outra vulnerabilidade de bypass de autorização, a CVE-2025-29927, com pontuação 9.1. Ao falsificar um cabeçalho de requisição, o invasor conseguia contornar a autenticação do middleware. Você achava que estava seguro porque havia configurado autenticação? Infelizmente, ela podia simplesmente ser ignorada.

Além dessas vulnerabilidades de alto risco, há ameaças cotidianas ainda mais frequentes:

Crawlers maliciosos e ataques DDoS. Uma API sem rate limiting pode ser derrubada em minutos. Já vi um endpoint de login receber 3.000 requisições por segundo em uma tentativa de força bruta contra senhas, até o servidor sair do ar.

Vazamento de dados. Sem um controle de permissões adequado, o usuário A pode visualizar os pedidos do usuário B. Quando um incidente desses vira notícia, a reputação da marca desmorona.

Ataques de injeção. Injeção de SQL, XSS, injeção de comandos… Pode parecer assunto antigo, mas inúmeros projetos continuam sendo atingidos todos os anos. Você pode pensar que o React faz o escape automático, mas, se a API não validar os dados, isso não resolve o problema.

Características das API Routes do Next.js

As API Routes do Next.js são diferentes de um backend tradicional e têm algumas características que exigem atenção:

Prioridade para Serverless. Ao implantar na Vercel, cada requisição à API é executada como uma função Serverless independente. A vantagem é o escalonamento automático; a desvantagem é não haver estado. Você não pode usar uma Session tradicional em memória e precisa recorrer a JWT ou a uma Session armazenada no banco de dados.

Frontend e backend no mesmo lugar. Como o código fica no mesmo repositório, um descuido com variáveis de ambiente pode expô-las ao cliente. Já vi alguém colocar DATABASE_URL no .env e acabar incluindo a variável no bundle do frontend durante a compilação, expondo tudo no GitHub.

Limitações da computação de borda. Se você usa Edge Runtime, algumas APIs do Node.js não estão disponíveis, e será necessário repensar bibliotecas de criptografia e conexões com bancos de dados. A solução de segurança também precisa acompanhar essas mudanças.

Em resumo, a API do Next.js é o seu backend, mas é mais leve, mais flexível e também mais fácil de configurar de forma insegura.

Autenticação de API na prática

Guia para escolher uma solução de autenticação

Vamos começar pela pergunta mais prática: devo usar JWT ou Session?

Quando comecei a desenvolver projetos com Next.js, também fiquei em dúvida. Há todo tipo de opinião na internet: algumas pessoas dizem que JWT é a solução moderna obrigatória, enquanto outras afirmam que Session é mais segura. Com o tempo, percebi que a escolha depende do cenário.

JWT é adequado nestes casos:

  • Sua aplicação será implantada em vários servidores, como funções Serverless ou nós de borda
  • Você precisa de autenticação entre domínios, por exemplo com o frontend em app.com e a API em api.com
  • Você não quer administrar o armazenamento de Session e prefere uma solução mais simples

Em essência, o JWT codifica as informações do usuário em um token. O servidor não mantém estado; basta enviar o token em cada requisição. Isso facilita muito o escalonamento horizontal.

Session é adequada nestes casos:

  • Você precisa de controle ativo no servidor, como desconectar um usuário ou aplicar imediatamente uma alteração de permissão
  • Os requisitos de segurança são muito altos e você não quer que o cliente mantenha nenhuma informação do usuário
  • Você já usa Redis ou um banco de dados, e administrar a Session não é um problema

Com Session, o estado fica no servidor, e o cliente recebe apenas um Session ID. Para invalidar o acesso de um usuário, basta excluir a Session. Isso não é possível da mesma forma com JWT.

Meu critério de escolha: uso JWT em projetos pequenos ou pessoais porque dá menos trabalho; em aplicações empresariais que exigem controle detalhado, uso Session. Não fique paralisado pela escolha: comece com uma das opções e mude depois se as necessidades do projeto exigirem.

Implementação completa de autenticação JWT

Vamos supor que você escolheu JWT. Como implementá-lo no Next.js?

Primeiro passo: gerar e verificar o token

Comece instalando uma biblioteca:

npm install jose

Por que não usar jsonwebtoken? Essa biblioteca não oferece suporte ao Edge Runtime. Já jose implementa padrões da Web e funciona em qualquer ambiente.

Crie lib/auth.ts:

import { SignJWT, jwtVerify } from 'jose';

const secret = new TextEncoder().encode(
  process.env.JWT_SECRET || 'your-secret-key-at-least-32-characters'
);

export async function createToken(payload: { userId: string }) {
  return new SignJWT(payload)
    .setProtectedHeader({ alg: 'HS256' })
    .setIssuedAt()
    .setExpirationTime('15m') // Expira após 15 minutos
    .sign(secret);
}

export async function verifyToken(token: string) {
  try {
    const { payload } = await jwtVerify(token, secret);
    return payload;
  } catch {
    return null;
  }
}

Observe a validade de 15m. Muita gente define 7 ou 30 dias. É mais cômodo, mas, se o token vazar, o invasor poderá usá-lo por muito tempo. A abordagem correta é combinar um Access Token de curta duração com um Refresh Token de longa duração.

Segundo passo: armazenar o token — não use localStorage

Este ponto é fundamental. Muitos tutoriais ensinam a armazenar o token no localStorage. Quando ocorre um ataque XSS, todos os tokens podem ser roubados.

A forma correta é usar um cookie HttpOnly.

Quando o endpoint de login retornar o token:

// app/api/login/route.ts
import { NextResponse } from 'next/server';
import { createToken } from '@/lib/auth';

export async function POST(request: Request) {
  // Valide o nome de usuário e a senha...

  const token = await createToken({ userId: user.id });

  const response = NextResponse.json({ success: true });
  response.cookies.set('token', token, {
    httpOnly: true,    // O JS não consegue ler; protege contra XSS
    secure: true,      // Transmissão apenas por HTTPS
    sameSite: 'lax',   // Protege contra CSRF
    maxAge: 900,       // 15 minutos, igual à validade do token
  });

  return response;
}

O HttpOnly é o ponto-chave: o JavaScript não consegue ler esse cookie, portanto um ataque XSS não pode roubá-lo.

Terceiro passo: proteger a API com middleware

Agora você tem um token. Como exigir login para acessar determinadas APIs? Use o Middleware do Next.js.

Crie middleware.ts:

import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
import { verifyToken } from './lib/auth';

export async function middleware(request: NextRequest) {
  const token = request.cookies.get('token')?.value;

  if (!token) {
    return NextResponse.json(
      { error: 'Unauthorized' },
      { status: 401 }
    );
  }

  const payload = await verifyToken(token);
  if (!payload) {
    return NextResponse.json(
      { error: 'Invalid token' },
      { status: 401 }
    );
  }

  // A validação passou; encaminhe as informações do usuário à API
  const requestHeaders = new Headers(request.headers);
  requestHeaders.set('x-user-id', payload.userId as string);

  return NextResponse.next({
    request: {
      headers: requestHeaders,
    },
  });
}

export const config = {
  matcher: '/api/protected/:path*',
};

Assim, todos os endpoints em /api/protected/* ficam protegidos automaticamente. Para obter as informações do usuário na API:

// app/api/protected/profile/route.ts
import { headers } from 'next/headers';

export async function GET() {
  const headersList = await headers();
  const userId = headersList.get('x-user-id');

  // Consulte as informações do usuário no banco de dados...
}

Quarto passo: implementar a renovação do token

Se o token expira em 15 minutos, o usuário precisará entrar na conta o tempo todo? É aqui que entra o Refresh Token.

O Access Token tem duração curta, de 15 minutos, enquanto o Refresh Token dura 30 dias. Quando o Access Token expira, o Refresh Token é usado para obter um novo, sem exigir outro login.

A implementação completa é um pouco mais complexa, mas essa é a ideia. Há várias soluções prontas, como a lógica incluída no next-auth.

Integração rápida com NextAuth.js

Para ser sincero, implementar tudo isso por conta própria ajuda a entender os princípios, mas exige bastante trabalho. Se você quer começar rapidamente, use o NextAuth.js, que agora se chama Auth.js.

A biblioteca oferece configurações seguras por padrão e prontas para uso:

  • Tratamento automático da proteção contra CSRF
  • Assinatura e criptografia de Session
  • Suporte a JWT e Session armazenada em banco de dados
  • Login com provedores como Google e GitHub

Instale a biblioteca:

npm install next-auth

Crie app/api/auth/[...nextauth]/route.ts:

import NextAuth from 'next-auth';
import CredentialsProvider from 'next-auth/providers/credentials';

const handler = NextAuth({
  providers: [
    CredentialsProvider({
      name: 'Credentials',
      credentials: {
        email: { label: "Email", type: "email" },
        password: { label: "Password", type: "password" }
      },
      async authorize(credentials) {
        // Valide o nome de usuário e a senha...
        if (user) {
          return { id: user.id, email: user.email };
        }
        return null;
      }
    })
  ],
  session: {
    strategy: 'jwt',  // Use JWT; adequado para Serverless
    maxAge: 30 * 24 * 60 * 60, // 30 dias
  },
  callbacks: {
    async jwt({ token, user }) {
      if (user) {
        token.userId = user.id;
      }
      return token;
    },
    async session({ session, token }) {
      session.userId = token.userId;
      return session;
    }
  }
});

export { handler as GET, handler as POST };

Depois, verifique o estado de login na API:

import { getServerSession } from 'next-auth';

export async function GET() {
  const session = await getServerSession();

  if (!session) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
  }

  // O usuário está autenticado; prossiga...
}

É simples assim. O NextAuth cuida do gerenciamento do token e da renovação da Session.

Configuração detalhada de CORS

O que é CORS e quais são os problemas mais comuns

CORS, ou compartilhamento de recursos entre origens, é um tema que causa dor de cabeça em muita gente. Tudo funciona no ambiente de desenvolvimento, mas, após a implantação, aparece este erro:

Access to fetch at 'https://api.example.com' from origin 'https://app.example.com'
has been blocked by CORS policy: No 'Access-Control-Allow-Origin' header is present.

Em termos simples, o navegador tem uma política de segurança: uma página no site A não pode acessar livremente recursos do site B. Se a página está em app.com e tenta chamar um endpoint em api.com, o navegador primeiro pergunta ao api.com: “Esta requisição veio de app.com; você permite?”. A API precisa responder claramente que permite antes de a requisição prosseguir.

Por que o erro não aparece no ambiente de desenvolvimento?

Durante o desenvolvimento no Next.js, frontend e API ficam em localhost:3000. Eles têm a mesma origem, então não há acesso entre domínios. Após a implantação, o frontend pode ficar na Vercel e a API em outro servidor, gerando o acesso entre origens.

O que é uma requisição Preflight?

Quando você envia uma requisição POST com um cabeçalho personalizado, como Authorization, o navegador primeiro envia uma requisição OPTIONS para testar a permissão. Isso é o Preflight. Se a API não tratar OPTIONS e responder com 404, o CORS falhará.

Já caí nessa armadilha: o endpoint POST estava pronto, mas esqueci de tratar OPTIONS. O frontend continuava mostrando um erro de CORS, e levei um bom tempo para encontrar a causa.

Três formas de configurar CORS no Next.js

Método 1: configuração global em next.config.js

É adequado quando todas as APIs permitem a mesma origem externa.

// next.config.js
module.exports = {
  async headers() {
    return [
      {
        source: '/api/:path*',
        headers: [
          { key: 'Access-Control-Allow-Origin', value: 'https://app.example.com' },
          { key: 'Access-Control-Allow-Methods', value: 'GET,POST,PUT,DELETE' },
          { key: 'Access-Control-Allow-Headers', value: 'Content-Type, Authorization' },
        ],
      },
    ];
  },
};

Vantagem: uma única configuração vale globalmente. Desvantagem: pouca flexibilidade para personalizar APIs individuais.

Método 2: configuração no Middleware

É adequado quando você precisa de uma decisão dinâmica e de tratamento centralizado.

// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';

export function middleware(request: NextRequest) {
  // Trate a requisição Preflight
  if (request.method === 'OPTIONS') {
    return new NextResponse(null, {
      status: 200,
      headers: {
        'Access-Control-Allow-Origin': 'https://app.example.com',
        'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE',
        'Access-Control-Allow-Headers': 'Content-Type, Authorization',
      },
    });
  }

  // Adicione os cabeçalhos CORS à requisição normal
  const response = NextResponse.next();
  response.headers.set('Access-Control-Allow-Origin', 'https://app.example.com');

  return response;
}

export const config = {
  matcher: '/api/:path*',
};

Essa abordagem tem acesso ao objeto request e permite decidir dinamicamente se a origem será aceita.

Método 3: configuração dentro da API Route

É adequado quando uma API específica tem requisitos especiais.

// app/api/public/route.ts
import { NextResponse } from 'next/server';

export async function GET() {
  const data = { message: 'Hello' };

  return NextResponse.json(data, {
    headers: {
      'Access-Control-Allow-Origin': '*', // API pública; permite todas as origens
    },
  });
}

export async function OPTIONS() {
  return new NextResponse(null, {
    status: 200,
    headers: {
      'Access-Control-Allow-Origin': '*',
      'Access-Control-Allow-Methods': 'GET',
      'Access-Control-Allow-Headers': 'Content-Type',
    },
  });
}

Observe que cada API precisa tratar OPTIONS; caso contrário, o Preflight não será aprovado.

Práticas recomendadas de segurança para CORS

1. Não use o curinga indiscriminadamente

É comum ver esta configuração:

'Access-Control-Allow-Origin': '*'

Isso significa que qualquer site pode chamar sua API. Para dados públicos, talvez não seja um problema; porém, se houver informações de usuário ou operações sensíveis, você ficará totalmente exposto.

A forma correta é especificar claramente os domínios permitidos:

const allowedOrigins = ['https://app.example.com', 'https://admin.example.com'];

const origin = request.headers.get('origin');
if (origin && allowedOrigins.includes(origin)) {
  response.headers.set('Access-Control-Allow-Origin', origin);
}

2. Tenha cuidado ao enviar credenciais

Se a API precisa ler cookies, como em uma autenticação por Session, o frontend deve fazer o seguinte:

fetch('https://api.example.com', {
  credentials: 'include',
});

O backend deve responder de forma compatível:

response.headers.set('Access-Control-Allow-Credentials', 'true');

No entanto, Access-Control-Allow-Origin não pode ser *. O navegador rejeita diretamente essa combinação; é obrigatório especificar um domínio concreto.

3. Trate as requisições Preflight

Lembre-se: requisições com o cabeçalho Authorization ou com Content-Type: application/json quase sempre acionam o Preflight. Sua API precisa responder ao método OPTIONS.

Você pode criar uma função reutilizável:

export function corsHeaders(origin?: string) {
  return {
    'Access-Control-Allow-Origin': origin || 'https://app.example.com',
    'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
    'Access-Control-Allow-Headers': 'Content-Type, Authorization',
    'Access-Control-Max-Age': '86400', // Armazene o resultado do Preflight por 24 horas
  };
}

Depois, basta reutilizá-la em cada API.

Rate limiting de APIs

Por que você precisa de rate limiting

Voltando à história do início: minha API recebeu 18 milhões de chamadas. Se eu tivesse aplicado um limite de 100 requisições por IP a cada minuto, o prejuízo talvez fosse de apenas algumas dezenas de dólares, e não de US$ 7.800.

Rate limiting significa limitar quantas vezes um usuário pode chamar uma API durante determinado período. Parece simples, mas tem várias funções importantes:

Proteção contra ataques DDoS. O invasor quer derrubar seu servidor com uma enxurrada de requisições? Com o limite ativo, as requisições que ultrapassam o teto por segundo são rejeitadas, e o servidor continua estável.

Proteção contra força bruta. Sem limite no endpoint de login, um invasor pode escrever um script que testa 10.000 senhas por segundo. Com o limite, cada IP pode tentar apenas 5 vezes por minuto, aumentando exponencialmente a dificuldade do ataque.

Proteção de recursos. Seu banco de dados e suas chamadas a APIs de terceiros têm custo. O rate limiting impede que um único usuário esgote os recursos e ajuda a manter o serviço disponível para todos.

Comparação entre soluções de rate limiting

No Next.js, há algumas soluções principais:

Solução 1: @upstash/ratelimit com Vercel KV

É a solução que mais uso atualmente. O Upstash é um Redis Serverless e parceiro oficial da Vercel, com integração muito simples.

Vantagens:

  • Compatível com Serverless, sem a necessidade de administrar um servidor Redis
  • Suporte a vários algoritmos: janela fixa, janela deslizante e token bucket
  • O nível gratuito é suficiente para projetos pessoais

Desvantagens:

  • Projetos com tráfego elevado precisam de um plano pago
  • Dependência de um serviço de terceiros

Solução 2: Redis autogerenciado

Se você já tem Redis ou não quer depender de terceiros, pode implementar sua própria solução.

Vantagens:

  • Controle total e nenhum custo adicional de serviço
  • Possibilidade de personalizar lógicas complexas

Desvantagens:

  • É necessário manter o servidor Redis
  • A configuração em ambientes Serverless é mais complexa

Solução 3: limite em memória

Não quer instalar Redis? É possível implementar um limite simples apenas em memória.

Vantagens:

  • Nenhuma dependência; poucas linhas de código resolvem
  • Adequado para o ambiente de desenvolvimento e projetos pequenos

Desvantagens:

  • Em ambientes Serverless, cada requisição pode usar uma nova instância, sem memória compartilhada, invalidando o limite
  • Ao reiniciar o servidor, todos os dados de limitação são perdidos

Minha recomendação: em projetos pessoais e implantações Serverless, use Upstash diretamente; em projetos empresariais com servidores próprios, use Redis; em demos ou no desenvolvimento local, uma solução em memória pode ser suficiente.

Exemplo prático de código

Vamos usar o Upstash e implementar a solução passo a passo.

Primeiro passo: instalar e configurar

npm install @upstash/ratelimit @upstash/redis

Crie um banco de dados Redis no site do Upstash, obtenha UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN e adicione-os ao .env:

UPSTASH_REDIS_REST_URL=https://xxx.upstash.io
UPSTASH_REDIS_REST_TOKEN=your-token

Segundo passo: criar o limitador

Crie lib/rate-limit.ts:

import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Crie o cliente Redis
const redis = new Redis({
  url: process.env.UPSTASH_REDIS_REST_URL!,
  token: process.env.UPSTASH_REDIS_REST_TOKEN!,
});

// Crie o limitador: janela deslizante com no máximo 10 requisições em 10 segundos
export const ratelimit = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
});

slidingWindow(10, '10 s') significa no máximo 10 requisições em 10 segundos. A janela deslizante é mais uniforme que a janela fixa e evita picos nas bordas de cada janela.

Terceiro passo: usar na API

// app/api/protected/route.ts
import { NextResponse } from 'next/server';
import { ratelimit } from '@/lib/rate-limit';

export async function GET(request: Request) {
  // Obtenha o IP do usuário
  const ip = request.headers.get('x-forwarded-for') || 'unknown';

  // Verifique o limite
  const { success, limit, remaining, reset } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      {
        error: 'Too many requests',
        limit,
        remaining,
        reset: new Date(reset),
      },
      {
        status: 429,
        headers: {
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      }
    );
  }

  // A requisição está dentro do limite; processe normalmente
  return NextResponse.json({ data: 'Success' });
}

O exemplo usa o IP como identificador do limite. Se houver login, você pode usar o userId:

const identifier = session?.userId || ip;
const { success } = await ratelimit.limit(identifier);

Assim, usuários autenticados são limitados por usuário, enquanto os demais são limitados por IP, o que aumenta a precisão.

Quarto passo: rate limiting global no middleware

Não quer repetir o código em cada API? Centralize o tratamento no Middleware:

// middleware.ts
import { ratelimit } from '@/lib/rate-limit';

export async function middleware(request: NextRequest) {
  const ip = request.ip || 'unknown';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests' },
      { status: 429 }
    );
  }

  return NextResponse.next();
}

export const config = {
  matcher: '/api/:path*',
};

Todas as APIs ficam protegidas automaticamente, sem esforço adicional.

Validação de entrada e defesa

Por que a validação de entrada é a primeira linha de defesa

“Nunca confie na entrada do usuário” é uma regra de ouro da segurança.

Seu frontend tem todos os tipos de validação de formulário? Isso não basta. Basta abrir as ferramentas de desenvolvedor e alterar o código para contornar as verificações. A defesa real precisa estar no servidor.

Injeção de SQL. Se o usuário inserir '; DROP TABLE users; -- e você concatenar o valor diretamente em uma consulta SQL, o banco de dados estará em risco. Embora hoje muita gente use ORM, ainda há vários cenários com SQL nativo.

Ataque XSS. O usuário envia <script>alert('hacked')</script>, você armazena esse conteúdo no banco de dados e, quando outro usuário abre a página, o script é executado e o cookie pode ser roubado. O React realmente faz escape automático, mas, se você usar dangerouslySetInnerHTML, continuará vulnerável.

Ataque DoS. O usuário envia um JSON de 10 MB e esgota a memória da função Serverless. Também pode enviar uma string enorme que faça uma expressão regular sofrer backtracking por um tempo interminável.

A validação de entrada bloqueia a maioria dos ataques básicos. Sem ela, as demais medidas de proteção ficam cheias de brechas.

Validação com segurança de tipos usando Zod

Zod é a biblioteca de validação que mais gosto de usar atualmente. Ela é escrita em TypeScript e se integra perfeitamente ao sistema de tipos.

Instale a biblioteca:

npm install zod

Uso básico

Defina um schema:

import { z } from 'zod';

const userSchema = z.object({
  email: z.string().email('Invalid email'),
  password: z.string().min(8, 'Password must be at least 8 characters'),
  age: z.number().int().min(18).max(120),
});

Valide os dados na API:

// app/api/register/route.ts
import { NextResponse } from 'next/server';
import { userSchema } from '@/lib/schemas';

export async function POST(request: Request) {
  const body = await request.json();

  // Valide os dados
  const result = userSchema.safeParse(body);

  if (!result.success) {
    return NextResponse.json(
      {
        error: 'Validation failed',
        details: result.error.format(),
      },
      { status: 400 }
    );
  }

  // A validação passou; obtenha os dados com segurança de tipos
  const { email, password, age } = result.data;

  // Continue o processamento...
}

Observe o uso de safeParse, que não lança uma exceção. Já parse lança uma exceção e exige try-catch.

Por que isso é melhor do que escrever a validação à mão?

Validação manual:

if (!body.email || typeof body.email !== 'string') {
  return error;
}
if (!body.email.includes('@')) {
  return error;
}
// Você pode continuar escrevendo para sempre...

Com Zod:

z.string().email()

Uma linha resolve o problema e ainda infere automaticamente o tipo.

Solução completa de validação de entrada

Validar não é apenas conferir o tipo de cada campo. Também é preciso considerar a lógica de negócio e os casos extremos.

1. Validar o corpo da requisição (body)

const postSchema = z.object({
  title: z.string().min(1).max(100),
  content: z.string().max(10000), // Limite o tamanho para evitar entradas enormes
  tags: z.array(z.string()).max(10), // Limite o tamanho do array
  publishedAt: z.string().datetime().optional(),
});

2. Validar parâmetros de consulta (query)

// app/api/posts/route.ts
export async function GET(request: Request) {
  const { searchParams } = new URL(request.url);

  const querySchema = z.object({
    page: z.coerce.number().int().min(1).default(1),
    limit: z.coerce.number().int().min(1).max(100).default(20),
    sort: z.enum(['asc', 'desc']).default('desc'),
  });

  const params = querySchema.parse({
    page: searchParams.get('page'),
    limit: searchParams.get('limit'),
    sort: searchParams.get('sort'),
  });

  // params.page será um número, com segurança de tipos
}

z.coerce.number() converte automaticamente uma string em número, o que é muito prático.

3. Criar regras de validação personalizadas

const passwordSchema = z.string()
  .min(8)
  .refine((val) => /[A-Z]/.test(val), 'Must contain uppercase')
  .refine((val) => /[a-z]/.test(val), 'Must contain lowercase')
  .refine((val) => /[0-9]/.test(val), 'Must contain number');

Também é possível fazer uma validação assíncrona:

const emailSchema = z.string().email().refine(
  async (email) => {
    const exists = await checkEmailExists(email);
    return !exists;
  },
  'Email already taken'
);

4. Tratar erros

As mensagens de erro do Zod são claras, mas podem ser personalizadas:

if (!result.success) {
  const errors = result.error.errors.map(err => ({
    field: err.path.join('.'),
    message: err.message,
  }));

  return NextResponse.json({ errors }, { status: 400 });
}

Uma resposta com erros estruturados é fácil de exibir no frontend.

Outras medidas de segurança

Além da validação, há outras medidas importantes:

1. Proteção contra CSRF

As Server Actions do Next.js têm proteção integrada contra CSRF. Elas comparam os cabeçalhos Origin e Host e rejeitam a requisição quando eles não correspondem.

Nas API Routes, é preciso implementar a proteção. O NextAuth faz isso automaticamente. Se você preferir uma implementação manual, pode usar um token CSRF:

// Gere o token e armazene-o em um cookie; envie-o com a requisição no frontend e compare-o no servidor

2. Content Security Policy (CSP)

Configure a CSP no next.config.js para limitar os recursos que a página pode carregar:

{
  headers: [
    {
      key: 'Content-Security-Policy',
      value: "default-src 'self'; script-src 'self'; style-src 'self';",
    },
  ],
}

Assim, mesmo que exista uma vulnerabilidade XSS, o script malicioso não conseguirá ser carregado.

3. Segurança de variáveis de ambiente

Há dois tipos de variável de ambiente no Next.js:

  • As que começam com NEXT_PUBLIC_* são expostas ao frontend
  • As que não têm o prefixo NEXT_PUBLIC_ ficam disponíveis apenas no servidor

Nunca coloque uma chave secreta em uma variável NEXT_PUBLIC_. Já vi alguém usar NEXT_PUBLIC_API_KEY para uma chave de API e expô-la diretamente.

4. Proteção contra injeção de SQL

ORMs como Prisma e Drizzle usam consultas parametrizadas automaticamente e praticamente eliminam esse problema.

Se você realmente precisar escrever SQL nativo, use parâmetros:

// ❌ Perigoso
db.query(`SELECT * FROM users WHERE id = ${userId}`);

// ✅ Seguro
db.query('SELECT * FROM users WHERE id = ?', [userId]);

5. Atualizar as dependências regularmente

Vulnerabilidades de segurança aparecem com frequência nas dependências. Execute regularmente:

npm audit
npm update

A vulnerabilidade do React que surgiu em fevereiro deste ano foi corrigida com a atualização para a versão mais recente. Não adie esse trabalho: uma atualização pode evitar muitos problemas.

Checklist completo de segurança

Depois de tantos detalhes, aqui está um checklist para revisar seu projeto:

Checklist de autenticação

  • ✅ O token é armazenado em um cookie HttpOnly, e não no localStorage
  • ✅ O Access Token tem validade de no máximo 30 minutos
  • ✅ Há um mecanismo de Refresh Token
  • ✅ A chave JWT tem pelo menos 32 caracteres e fica em uma variável de ambiente
  • ✅ Os atributos secure e sameSite estão ativos no cookie
  • ✅ Um Middleware protege as APIs sensíveis

Checklist de configuração de CORS

  • ✅ APIs sensíveis não usam Access-Control-Allow-Origin: *
  • ✅ A lista de domínios permitidos está especificada claramente
  • ✅ As requisições OPTIONS de Preflight são tratadas corretamente
  • ✅ Quando é necessário enviar credenciais, Access-Control-Allow-Credentials: true está configurado
  • ✅ A configuração de CORS foi verificada no ambiente de produção

Checklist de rate limiting

  • ✅ Endpoints críticos, como login, cadastro e redefinição de senha, têm limites rigorosos
  • ✅ As estratégias de limite diferenciam usuários autenticados e não autenticados
  • ✅ A API retorna o status 429 e o cabeçalho Retry-After
  • ✅ Redis, Upstash ou outro armazenamento persistente é usado para evitar falhas em Serverless
  • ✅ Os acionamentos do limite são monitorados e os valores são ajustados quando necessário

Checklist de validação de entrada

  • ✅ Todas as entradas do usuário são validadas no servidor
  • ✅ Zod ou outra ferramenta semelhante é usada para validação com segurança de tipos
  • ✅ Há limites máximos para strings, arrays e objetos
  • ✅ Formatos como e-mail, URL e data são validados
  • ✅ A API retorna mensagens claras de erro de validação

Checklist de auditoria periódica

  • npm audit é executado pelo menos uma vez por mês, e vulnerabilidades de alto risco são corrigidas
  • ✅ Next.js e React são atualizados rapidamente para a versão estável mais recente
  • ✅ Os avisos de segurança do Next.js são acompanhados
  • ✅ As variáveis de ambiente são revisadas para garantir que nenhuma chave vazou para o frontend
  • ✅ A lógica de autenticação e permissões recebe atenção especial no Code Review

Imprima este checklist e deixe-o sobre a mesa. Revise-o antes de iniciar um novo projeto e novamente antes da implantação.

Conclusão

Depois de tudo isso, a ideia central cabe em uma frase: segurança de API é um trabalho sistêmico e contínuo, não uma tarefa que você resolve uma vez e esquece.

Pode parecer trabalhoso configurar autenticação, CORS, rate limiting e validação. Porém, quando algo dá errado — com vazamento de dados, servidor derrubado ou uma conta astronômica — o custo de corrigir depois é muito maior.

Pela minha experiência, configurar tudo do zero leva de meio dia a um dia. Depois que a base está pronta, normalmente basta copiar, colar e ajustar alguns parâmetros; em um novo projeto, isso leva pouco mais de dez minutos. O mais importante é poder dormir tranquilo, sem medo de acordar e descobrir que sofreu um ataque.

Próximas ações:

  1. Revise seu projeto agora, usando o checklist para identificar o que está faltando
  2. Comece pelo essencial, adicionando autenticação e rate limiting antes de aperfeiçoar o restante
  3. Acompanhe notícias de segurança, como o blog oficial do Next.js e o GitHub Security Advisories
  4. Compartilhe com a equipe, porque segurança é responsabilidade de todos, não de uma única pessoa

Vale repetir: a vulnerabilidade do React com pontuação CVSS 10.0, divulgada em dezembro de 2025, afetou um grande número de projetos. Se você ainda não atualizou, faça o upgrade para a versão mais recente o quanto antes. Atualizações de segurança não podem esperar.

O caminho para proteger uma API é longo, mas cada etapa vale a pena. Espero que este artigo ajude você a evitar alguns erros e a implementar essas defesas quanto antes.

FAQ

Uma API no Next.js deve usar autenticação JWT ou Session?
Depende do cenário. Implantações Serverless e autenticação entre domínios combinam com JWT, pois ele não mantém estado e é fácil de escalar. Cenários que exigem controle ativo pelo servidor, como desconectar um usuário, ou têm requisitos de segurança muito altos combinam com Session. Recomendo JWT para projetos pessoais e Session para aplicações empresariais.
Por que não devo armazenar o token JWT no localStorage?
Porque o JavaScript pode ler o localStorage. Se um ataque XSS for bem-sucedido, o token poderá ser roubado. O ideal é usar um cookie HttpOnly, que fica totalmente inacessível ao JavaScript e impede o roubo do token mesmo quando existe uma vulnerabilidade XSS.
Por que não recebo erro de CORS no desenvolvimento, mas recebo em produção?
Durante o desenvolvimento, frontend e API normalmente estão em localhost:3000, portanto têm a mesma origem e não envolvem acesso entre domínios. Em produção, eles podem estar em domínios diferentes, fazendo o navegador executar a verificação de CORS. A solução é configurar corretamente o cabeçalho Access-Control-Allow-Origin na API.
Como implementar rate limiting em um ambiente Serverless?
Recomendo @upstash/ratelimit com Vercel KV. Cada requisição de uma função Serverless pode rodar em uma nova instância, sem memória compartilhada, o que inviabiliza um limite baseado apenas em memória. O Upstash Redis fornece armazenamento persistente e compartilha os dados de limite entre todas as instâncias, adequando-se perfeitamente a Serverless.
Como impedir tentativas de força bruta contra senhas em uma API?
Implemente várias camadas de proteção: 1) aplique rate limiting rigoroso ao endpoint de login, como no máximo 5 tentativas por IP a cada minuto; 2) valide a força da senha com Zod; 3) implemente o bloqueio da conta por 30 minutos após 5 falhas consecutivas; 4) adicione um CAPTCHA para elevar o custo do ataque.

23 min de leitura · Publicado em: 5 jan 2026 · Atualizado em: 4 set 2026

Comentários

Entre com GitHub para comentar

Easton BlogEaston Blog