O que é realmente um JWT
Um JSON Web Token é uma string compacta e segura para URLs que codifica um conjunto de claims junto com uma assinatura sobre elas. Ele é definido pela RFC 7519 e construído sobre duas especificações complementares: JSON Web Signature (JWS) para assinatura e JSON Web Encryption (JWE) para confidencialidade. Quase todo JWT que você encontrará na prática é um JWS.
O token é composto por três partes codificadas em Base64url e separadas por pontos: header, payload e assinatura. Ele é self-contained, o que significa que tudo o que um servidor precisa para tomar uma decisão viaja junto com a requisição. Validá-lo não requer consultas ao banco de dados, nem armazenamento de sessão compartilhado e nem chamadas ao emissor.
Vale a pena ser preciso sobre o que isso prova. Um JWT demonstra que quem o emitiu assinou exatamente esse payload. Ele não prova que o token foi destinado a você, a menos que você verifique a audience. Ele não prova que o usuário ainda tem permissão para agir, a menos que você verifique scopes e roles. E ele não esconde nada, a menos que você use JWE. Cada propriedade de segurança que for importante para você deve ser verificada explicitamente.
As três partes, decodificadas
O header é um pequeno objeto JSON que nomeia o algoritmo de assinatura e o tipo do token. O campo alg é o mais crítico. typ é quase sempre JWT, e kid identifica qual chave foi utilizada para que os verificadores possam encontrar a correta durante a rotação de chaves.
{ "alg": "HS256", "typ": "JWT", "kid": "2026-09" }
O payload é um objeto JSON de claims. As registered claims possuem nomes padronizados definidos pela especificação: iss para o emissor (issuer), sub para o assunto (subject), aud para a audiência (audience), exp para a expiração, nbf para “não antes de” (not before), iat para a data de emissão (issued at) e jti para um ID único do token. Todo o restante são custom claims que você mesmo define.
{
"iss": "https://auth.example.com",
"sub": "user_42",
"aud": "api.example.com",
"exp": 1760000000,
"iat": 1759999100,
"scope": "read:posts write:posts",
"role": "editor"
}
A signature é computada sobre o Base64url do header e do payload. Altere um único caractere em qualquer um deles e a assinatura recomputada não coincidirá. Esta é a propriedade que torna um JWT seguro para ser entregue a um cliente não confiável, e é a única coisa que separa uma claim de uma falsificação.
Assinado, não criptografado
O mal-entendido mais comum sobre JWTs é que eles são secretos. Eles não são. O payload está em Base64url, que é uma codificação, não criptografia. Qualquer pessoa com o token pode decodificar cada claim com apenas uma linha de código.
const [, payload] = token.split(".");
console.log(JSON.parse(atob(payload)));
// { sub: "user_42", role: "editor", scope: "read:posts" }
Isso traz duas consequências. Primeiro, nunca coloque segredos, senhas ou dados pessoais em um JWT que você não se sentiria confortável em mostrar ao cliente. Segundo, trate o próprio token como uma credencial: a posse é suficiente para agir como o sujeito, e é por isso que o armazenamento e o transporte são tão importantes mais adiante neste guia.
Se você realmente precisar esconder o payload do cliente, use JSON Web Encryption e uma biblioteca que o suporte. Para a vasta maioria dos sistemas, um JWS assinado via TLS é a resposta correta, e adicionar criptografia apenas adicionaria mais uma complexidade desnecessária.
Algoritmos de assinatura: HS256 vs RS256
Duas famílias de algoritmos dominam as implementações reais.
HS256 é HMAC com SHA-256. O mesmo segredo assina e verifica. É rápido, simples e ideal quando um único serviço emite e verifica seus próprios tokens.
import { SignJWT, jwtVerify } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
const token = await new SignJWT({ role: "editor" })
.setProtectedHeader({ alg: "HS256" })
.setSubject("user_42")
.setExpirationTime("15m")
.sign(secret);
await jwtVerify(token, secret, { algorithms: ["HS256"] });
RS256 é RSA com SHA-256, e ES256 é ECDSA sobre uma curva prima. O emissor detém uma chave privada e assina; todos os outros detêm a chave pública e verificam. Essa assimetria é a razão pela qual sistemas grandes o preferem: um servidor de recursos pode verificar tokens sem ser capaz de emiti-los. O ES256 oferece a mesma garantia com chaves e assinaturas muito menores.
import { importPKCS8, importSPKI, SignJWT, jwtVerify } from "jose";
const privateKey = await importPKCS8(process.env.JWT_PRIVATE_KEY!, "RS256");
const publicKey = await importSPKI(process.env.JWT_PUBLIC_KEY!, "RS256");
const token = await new SignJWT({ role: "editor" })
.setProtectedHeader({ alg: "RS256", kid: "2026-09" })
.setSubject("user_42")
.setExpirationTime("15m")
.sign(privateKey);
await jwtVerify(token, publicKey, {
algorithms: ["RS256"],
issuer: "https://auth.example.com",
audience: "api.example.com",
});
A regra que importa mais do que a escolha: o verificador deve fixar o algoritmo esperado. Nunca permita que o próprio header do token selecione como ele deve ser verificado.
Claims: registradas e customizadas
As registered claims são reservadas pela especificação e compreendidas por qualquer biblioteca séria.
iss— quem emitiu o token. Verifique isso para rejeitar tokens de outro ambiente.sub— sobre quem é o token, geralmente o id do usuário.aud— para quem é o token. Um token emitido para sua API pública não deve ser aceito por sua API de administração.exp— quando o token expira, como um Unix timestamp. Sempre defina este valor.nbf— não antes (not before). Raramente necessário, mas útil para rollouts graduais.iat— quando foi emitido. Útil para verificações de idade máxima e debugging.jti— um id único para este token, usado para detecção de replay e deny-lists.
As custom claims carregam dados da aplicação: scope, role, tenant_id, email. Mantenha-as pequenas e sem informações sensíveis. Um token viaja em cada requisição, portanto, um payload inflado é um custo permanente em banda e latência.
Existe uma forte tentação de colocar todo o perfil do usuário no token para evitar uma leitura no banco de dados. Resista a isso. As claims ficam obsoletas no momento em que uma role muda, e você não pode “desemitir” um token que o cliente já possui. Coloque apenas o que o verificador genuinamente precisa e busque o restante no banco.
Criando e verificando tokens
No Node, jose é a escolha moderna. Ele é baseado em promises, funciona em qualquer runtime, incluindo Cloudflare Workers e Deno, e expõe uma API concisa e cuidadosa. jsonwebtoken é a biblioteca mais antiga, baseada em callbacks, e continua sendo comum em códigos legados.
import { SignJWT } from "jose";
export async function issueAccessToken(userId: string, scope: string) {
return new SignJWT({ scope })
.setProtectedHeader({ alg: "HS256", typ: "JWT" })
.setSubject(userId)
.setIssuer("https://auth.example.com")
.setAudience("api.example.com")
.setIssuedAt()
.setExpirationTime("15m")
.setJti(crypto.randomUUID())
.sign(new TextEncoder().encode(process.env.JWT_SECRET));
}
A verificação é onde a segurança reside. Um verificador deve checar a assinatura, fixar o algoritmo e validar exp, iss e aud. Uma biblioteca pode decodificar um token sem verificá-lo tranquilamente, e esse payload decodificado é um input controlado por um atacante.
import { jwtVerify, type JWTPayload } from "jose";
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
export async function verifyAccessToken(token: string): Promise<JWTPayload> {
const { payload } = await jwtVerify(token, secret, {
algorithms: ["HS256"],
issuer: "https://auth.example.com",
audience: "api.example.com",
});
return payload;
}
Observe que jwtVerify impõe exp e nbf automaticamente, mas só verifica iss e aud quando você os passa. Omiti-los é uma vulnerabilidade real: um token emitido para um serviço de staging seria aceito em produção, e um token emitido para uma aplicação diferente também seria aceito.
Access tokens e refresh tokens
Um único token de longa duração é conveniente, porém perigoso. A solução padrão é utilizar um par com tempos de vida e públicos diferentes.
- O access token tem vida curta, tipicamente de 5 a 15 minutos. Ele é enviado em cada chamada de API e é o único token que o servidor de recursos visualiza.
- O refresh token tem vida longa, variando de dias a meses. Ele é enviado apenas ao servidor de autorização, em troca de um novo access token.
Essa divisão limita os danos. Se um access token vazar, ele se tornará inútil em poucos minutos. O refresh token, que é muito mais sensível, nunca viaja para os servidores de recursos e pode ser rotacionado e revogado, que é onde reside o controle real.
Rotação de refresh token
A rotação significa que cada renovação emite um novo refresh token e invalida o anterior. Se um invasor roubar um refresh token e utilizá-lo, o cliente legítimo apresentará posteriormente um token já utilizado, permitindo que o servidor detecte o reuso e revogue toda a família de tokens.
import { randomUUID } from "node:crypto";
export async function rotateRefreshToken(presented: string) {
const stored = await db.refreshToken.findUnique({ where: { token: presented } });
if (!stored || stored.revokedAt || stored.expiresAt < new Date()) {
if (stored) await revokeFamily(stored.familyId);
throw new Error("invalid_refresh_token");
}
await db.refreshToken.update({
where: { id: stored.id },
data: { revokedAt: new Date(), replacedBy: randomUUID() },
});
return issueTokenPair(stored.userId, stored.familyId);
}
Armazene os refresh tokens com hash, exatamente como você faria com senhas. Um vazamento de banco de dados não deve entregar credenciais válidas a um invasor, e um refresh token nada mais é do que uma senha que ignora o formulário de login.
Onde armazenar tokens no navegador
Não existe um lugar perfeito, apenas trade-offs entre cross-site scripting e cross-site request forgery.
- localStorage pode ser lido por qualquer script na página. Um único bug de XSS e o atacante exfiltra todos os tokens. Este é o erro que continua aparecendo em relatórios de violações de segurança.
- In-memory, como uma variável em um módulo, é seguro contra XSS persistente, mas é perdido ao atualizar a página; por isso, geralmente é combinado com um refresh token em um cookie httpOnly.
- Um cookie httpOnly, Secure e SameSite não pode ser lido por JavaScript, o que neutraliza o roubo de tokens via XSS. No entanto, isso reintroduz o CSRF, que é mitigado por
SameSite=LaxouStrictsomado a um token CSRF.
Para uma aplicação de navegador, o padrão pragmático é um access token de curta duração mantido em memória e um refresh token rotativo em um cookie httpOnly com escopo limitado ao endpoint de refresh. Clientes nativos e server-side não possuem essa restrição e podem manter tokens em armazenamento seguro ou simplesmente em memória.
O trade-off da ausência de estado e a revogação
O grande atrativo dos JWTs é a ausência de estado (statelessness). Qualquer servidor pode verificar um token sem a necessidade de um estado compartilhado, o que escala perfeitamente entre regiões e torna o escalonamento horizontal trivial. O custo disso é que a revogação é genuinamente difícil. Um token assinado é válido até expirar, independentemente de você ter deletado o usuário, alterado a função dele ou feito o logout. Não existe um registro central para deletar.
Você pode recuperar parte desse controle:
- Mantenha os access tokens com tempo de vida curto, para que a janela de revogação seja de minutos, e não de dias.
- Mantenha uma deny-list de valores
jtipara casos raros de logout imediato, verificando-a a cada requisição. Isso reintroduz o estado, portanto, mantenha-a pequena e com expiração. - Adicione um claim de
token_versionpor usuário e rejeite tokens cuja versão esteja obsoleta. Alterar a senha ou a função do usuário incrementa esse valor.
Este é o resumo honesto: JWTs trocam a facilidade de revogação pela facilidade de escalonamento. Se você precisa de revogação instantânea em todos os lugares, cookies de sessão apoiados por um store podem ser mais adequados, como abordado em Session Auth.
Confusão de algoritmos e alg none
Dois ataques são antigos o suficiente para estarem nos livros didáticos e ainda assim encontrarem vítimas.
O primeiro é o alg: none. Um atacante edita o header para {"alg":"none"} e remove a assinatura. Um verificador ingênuo que confia no header aceita o token forjado. O segundo é a confusão HS/RS. Um serviço que espera tokens RS256 é enganado para aceitar um token HS256 assinado com a chave pública RSA, que é pública por definição.
Ambos possuem a mesma solução: o verificador decide o algoritmo, não o token.
// Good: the verifier decides.
await jwtVerify(token, secret, { algorithms: ["HS256"] });
// Bad: the token decides.
const { header } = decodeProtectedHeader(token);
await jwtVerify(token, secret, { algorithms: [header.alg] });
Rejeite alg: none sumariamente, nunca derive a chave de uma fonte não confiável e trate o header como dados, não como instruções.
Escopos e autorização
A autenticação responde quem é o usuário, e os escopos respondem o que ele pode fazer. Um claim de scope é uma lista de permissões delimitada por espaços, e o middleware a verifica antes que um handler seja executado.
export function requireScope(required: string) {
return (req, res, next) => {
const granted = String(req.user.scope ?? "").split(" ");
if (!granted.includes(required)) {
return res.status(403).json({ error: "insufficient_scope" });
}
next();
};
}
Mantenha os escopos abrangentes e estáveis, e aplique-os no servidor. Um token sem o escopo correto deve falhar com 403, não 401: o chamador está autenticado, mas não tem permissão. Para modelos de acesso mais complexos baseados em funções e atributos, consulte RBAC.
JWT vs tokens opacos
Um JWT é um bearer token que carrega sua própria validação. Um token opaco é uma string aleatória sem significado intrínseco, portanto, o servidor precisa consultá-lo para obter qualquer informação.
Tokens opacos levam vantagem em revogação e privacidade. Você pode deletar a sessão instantaneamente, e o token não revela nada caso vaze. O custo disso é uma ida e volta ao banco de dados ou cache em cada requisição. Já os JWTs vencem em escala e independência. Os serviços fazem a verificação localmente e não precisam de um armazenamento compartilhado, ao custo de haver uma janela de tempo para a revogação.
Muitos sistemas em produção utilizam ambos: um access token em JWT para velocidade e um refresh token opaco para controle. Esse modelo híbrido é o que a maioria dos provedores de identidade entrega hoje em dia, e é uma ótima escolha padrão quando você está em dúvida.
Rotação de chaves com kid
Chaves de assinatura não devem durar para sempre. A rotação limita os danos de uma chave comprometida, e o campo de cabeçalho kid é o que torna a rotação invisível para os clientes: o verificador lê o kid, seleciona a chave correspondente e valida a assinatura.
import { SignJWT, jwtVerify, createLocalJWKSet } from "jose";
const jwks = createLocalJWKSet({
keys: [{ kty: "oct", kid: "2026-09", k: process.env.JWT_SECRET }],
});
// The verifier resolves the key from the header's kid.
await jwtVerify(token, jwks, { algorithms: ["HS256"] });
Ao realizar a rotação, publique a nova chave ao lado da antiga, assine novos tokens com o novo kid e continue verificando a chave antiga até que todos os tokens assinados com ela tenham expirado. Se removê-la cedo demais, você desconectará todos os usuários ativos de uma só vez. Com chaves assimétricas, publique um documento JWKS em uma URL well-known e permita que os servidores de recurso façam o cache dele.
Tempo de vida de tokens na prática
A expiração é um equilíbrio entre segurança e conveniência. Não existe uma resposta universal correta, mas a estrutura de um padrão sensato é consistente.
- Access tokens: 5 a 15 minutos. Curto o suficiente para que um vazamento se torne inútil rapidamente, e longo o suficiente para que você não precise fazer o refresh em cada requisição.
- Refresh tokens: 7 a 30 dias, com rotação e sliding window. Longo o suficiente para manter os usuários conectados, mas curto o suficiente para que um token abandonado eventualmente expire.
- Limite absoluto de sessão: 30 a 90 dias. Uma idade máxima após a qual o usuário deve se autenticar novamente, independentemente de quantas vezes ele tenha feito o refresh.
Se os seus usuários reclamarem de serem desconectados, a solução é um silent refresh mais fluido, e não um access token mais longo. Um access token de 24 horas é um problema de revogação esperando para acontecer.
Depurando um token sem confiar nele
Quando uma requisição falha com 401, você quer ver o que o token afirma sem comprometer a verificação. A decodificação é segura, desde que você trate o resultado como dados não confiáveis.
import { decodeJwt, decodeProtectedHeader } from "jose";
const header = decodeProtectedHeader(token);
const claims = decodeJwt(token);
console.log({ alg: header.alg, kid: header.kid });
console.log({
sub: claims.sub,
iss: claims.iss,
aud: claims.aud,
exp: new Date((claims.exp ?? 0) * 1000).toISOString(),
expired: (claims.exp ?? 0) * 1000 < Date.now(),
});
As duas falhas que você mais verá são uma divergência de aud após a renomeação de um serviço e uma divergência de iss após a mudança entre ambientes. Ambos são problemas de configuração e ambos são invisíveis até que você imprima as claims.
Testando tokens
Você deve ser capaz de testar uma rota autenticada sem precisar subir um provedor de identidade. Como um JWT é apenas uma string assinada, um helper de teste que assine um token com o mesmo secret de teste é o suficiente.
import { SignJWT } from "jose";
import request from "supertest";
import app from "../app.js";
const secret = new TextEncoder().encode("test-secret");
async function tokenFor(scope = "read:posts") {
return new SignJWT({ scope })
.setProtectedHeader({ alg: "HS256" })
.setSubject("user_1")
.setIssuer("https://auth.example.com")
.setAudience("api.example.com")
.setExpirationTime("5m")
.sign(secret);
}
test("rejects a request without a token", async () => {
await request(app).get("/posts").expect(401);
});
test("accepts a request with a valid token", async () => {
const token = await tokenFor();
await request(app)
.get("/posts")
.set("authorization", `Bearer ${token}`)
.expect(200);
});
Teste também os casos negativos: um token expirado, um token com a audience incorreta e um token assinado com uma chave diferente. Essas são as verificações que protegem sua aplicação, portanto, elas merecem cobertura tanto quanto o caminho feliz (happy path).
Melhores práticas
- Sempre defina
exp; mantenha os access tokens com duração de 15 minutos ou menos. - Fixe o algoritmo no verifier e rejeite
alg: none. - Valide
iss,aud,expenbf; nunca confie em um claim que você não verificou. - Mantenha secrets e chaves privadas fora do controle de versão e carregue-as de um secret manager.
- Armazene refresh tokens com hash e realize a rotação a cada uso.
- Mantenha os claims pequenos e não sensíveis; o payload é legível.
- Em navegadores, prefira cookies httpOnly ou memória em vez de localStorage.
- Planeje a revogação com tempos de vida curtos, uma deny-list de
jtiou uma versão do token. - Use
josepara códigos novos; ele é baseado em promises e portátil entre runtimes.
Erros comuns
- Assumir que o payload está criptografado só porque parece um amontoado de caracteres aleatórios.
- Ler claims com
decodee tratá-las como verificadas. - Permitir que o header
algdo token escolha o algoritmo de verificação. - Aceitar um token sem verificar o
aud, fazendo com que tokens de staging funcionem em produção. - Usar um secret fraco ou compartilhado, ou commitá-lo no repositório.
- Definir a expiração para 30 dias porque fazer o refresh é incômodo.
- Armazenar tokens no localStorage e achar que o problema está resolvido.
- Colocar uma role no token e nunca invalidá-la quando a role muda.
- Tratar 401 e 403 como se fossem o mesmo erro.
Próximos passos
JWTs são apenas uma ferramenta dentro de um kit de identidade mais amplo. O guia de OAuth 2.0 mostra como os tokens são obtidos através de autorização delegada, Session Auth aborda a alternativa baseada em cookies para quando você precisa de revogação instantânea, e API Keys explica credenciais de longa duração para clientes de máquina. Se você quiser ver este código de verificação dentro de um servidor real, leia Node.js.