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

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
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
secureesameSiteestã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: trueestá 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:
- Revise seu projeto agora, usando o checklist para identificar o que está faltando
- Comece pelo essencial, adicionando autenticação e rate limiting antes de aperfeiçoar o restante
- Acompanhe notícias de segurança, como o blog oficial do Next.js e o GitHub Security Advisories
- 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?
Por que não devo armazenar o token JWT no localStorage?
Por que não recebo erro de CORS no desenvolvimento, mas recebo em produção?
Como implementar rate limiting em um ambiente Serverless?
Como impedir tentativas de força bruta contra senhas em uma API?
23 min de leitura · Publicado em: 5 jan 2026 · Atualizado em: 4 set 2026
Guia completo Next.js
Se você chegou pela busca, o caminho mais rápido é ir para o post anterior ou próximo desta série.
Anterior
Guia completo de API Routes no Next.js: de Route Handlers às melhores práticas de tratamento de erros
Guia completo de API Routes no Next.js: aprenda a criar Route Handlers, processar requisições, tratar erros e projetar respostas para desenvolver APIs de backend com Next.js.
Parte 11 de 26
Próximo
Aplicativo de chat em tempo real com Next.js: como usar WebSocket e SSE corretamente
Uma comparação detalhada entre WebSocket, SSE e Long Polling para comunicação em tempo real, com experiências práticas de implantação na Vercel, integração com Socket.io, gerenciamento de estado das mensagens e exemplos completos de otimização de desempenho.
Parte 13 de 26



Comentários
Entre com GitHub para comentar