Começa por aqui: você clica em "Sair". Na sessão, você fica fora na hora. No JWT, o token continua valendo até expirar.
Parece detalhe, mas essa diferença diz muito sobre como cada um funciona. Estudei sobre o assunto e quero compartilhar com vocês, afinal a melhor forma de aprender é ensinando. No fim do artigo você vai saber como cada um funciona, o preço de cada um e quando usar qual.
É um conteúdo introdutório, a ideia é simplificar. O contexto: numa conversa com o tech lead, veio a pergunta "JWT ou sessão?". Como era uma aplicação web, a escolha foi sessão. Este artigo é pra entender o porquê dessa resposta, e quando ela seria outra.
O problema: o HTTP não lembra de você
O HTTP, protocolo da web, é stateless: ele não lembra de nada entre uma requisição e outra. Cada requisição chega "do zero".
Por exemplo, você loga no e-mail e clica numa mensagem. Pro servidor, esse clique chega sem nenhuma memória do login que você acabou de fazer. Ou seja, depois que você digitou e-mail e senha, alguma coisa precisa viajar em toda requisição seguinte dizendo "sou eu, já fiz login".
E essa "coisa" é valiosa. A OWASP (a comunidade que mantém os guias de segurança web mais usados) lembra que, depois do login, o identificador da sessão vale temporariamente o mesmo que a credencial mais forte que o usuário usou. Quem rouba esse identificador vira o usuário, e isso se chama session hijacking (sequestro de sessão).
Existem dois jeitos principais de resolver isso: Sessão e JWT.
Um aviso antes de começar: é comum ver a comparação "cookie vs JWT", mas isso é comparar maçã com laranja. Cookie é um lugar de guardar coisa no navegador; JWT é um formato de token. As comparações certas são sessão vs JWT (onde fica o estado) e cookie vs localStorage (onde o navegador guarda).
Sessão: o servidor guarda, o navegador leva só um número
Na Sessão, o servidor guarda quem está logado num lugar dele: na memória, num banco ou num banco em memória como o Redis. Esse lugar é o session store.
Pro navegador, ele devolve só um número aleatório, o session ID, dentro de um cookie: um espaço do navegador que vai junto em toda requisição para aquele site, sozinho, sem o JavaScript fazer nada.
Na prática, a conversa entre navegador e servidor fica mais ou menos assim:
POST /login
(e-mail + senha)
HTTP/1.1 200 OK
Set-Cookie: session_id=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
E a partir daí, em toda requisição:
GET /emails
Cookie: session_id=abc123
Do lado do servidor, cada requisição faz uma consulta ao session store. De forma simplificada:
// pseudocódigo
const sessionId = request.cookies.session_id // "abc123"
const sessao = await sessionStore.get(sessionId)
if (!sessao) {
return responder(401) // sessão inválida: volta pro login
}
const usuario = sessao.usuario // "Vinicius"
return responder(200, buscarEmails(usuario))
A analogia do guarda-volumes
Pensa num guarda-volumes: você entrega a mochila e recebe uma ficha com um número.
A ficha é o session ID, a mochila são os seus dados e o balcão é o servidor. A ficha sozinha não diz nada, quem sabe o que tem atrás dela é o balcão.
O cookie da sessão
Como o session ID viaja em cookie, a segurança dele depende dos atributos que vêm junto no Set-Cookie:
- HttpOnly: o JavaScript da página não consegue ler o cookie. Ajuda contra XSS (quando um atacante consegue rodar JavaScript dele dentro do seu site).
- Secure: o cookie só vai por HTTPS.
-
SameSite: controla se o cookie vai junto em requisições que vêm de outro site.
Strictnunca vai;Laxvai também quando o usuário navega pro seu site vindo de outro (clicou num link);Nonevai sempre (e exigeSecure).
A OWASP também recomenda trocar o session ID por cookie, e não por parâmetro na URL, porque na URL ele vaza em log, histórico e favoritos. O exemplo que ela dá para um cookie de sessão é:
Set-Cookie: __Host-SessionID=<value>; Secure; HttpOnly; SameSite=Strict; Path=/
O lado bom: sair é apagar o registro
O logout é simples: o servidor apaga o registro abc123 do session store. Na próxima requisição, a consulta não encontra nada e você já está fora. É o 401 da imagem lá de cima.
O mesmo vale pra "sair de todos os dispositivos" ou pra tirar a permissão de admin de alguém: como o servidor consulta a sessão a cada requisição, a mudança vale na hora.
Porém, nem tudo são flores: CSRF
O cookie ir sozinho em toda requisição é o ponto forte da sessão e também o ponto fraco.
Como o cookie vai sozinho, outro site pode fazer seu navegador mandar uma ação com ele. Isso é o CSRF (Cross-Site Request Forgery, ou falsificação de requisição entre sites).
Vamos com o exemplo clássico da PortSwigger pra entender melhor: um site tem um POST /email/change que troca o e-mail da conta, e identifica o usuário só pelo cookie de sessão. O atacante monta uma página com um formulário escondido apontando pra esse endereço, com o e-mail dele e um script que envia o formulário sozinho. Você, logado, abre essa página, o navegador manda o cookie junto e o seu e-mail é trocado. Depois é só o atacante pedir "esqueci minha senha" pra tomar a conta.
O CSRF tem defesa conhecida:
- SameSite no cookie, que segura o cookie em pedidos vindos de outro site.
- Um token aleatório no formulário (o synchronizer token pattern): o servidor gera um valor imprevisível, coloca no formulário e confere em todo POST. O site do atacante não tem como adivinhar esse valor.
A OWASP trata o SameSite como defesa em profundidade e, na maioria dos casos, não como substituto do token.
Outro custo: o estado fica no servidor
Toda requisição consulta o lugar onde as sessões ficam. É uma ida a mais na rede a cada clique.
E com mais de um servidor atrás de um load balancer, a sessão criada no servidor A não existe no servidor B se ela estiver na memória dele. Por isso, quando a aplicação cresce, o comum é usar um store compartilhado, como um Redis dedicado às sessões.
E aí vem a pergunta: e se o próprio token carregasse quem você é?
JWT: os dados vão no próprio token
É isso que o JWT (JSON Web Token, definido na RFC 7519) faz.
No JWT, o servidor assina o token com uma chave secreta que só ele tem e entrega pro cliente. Em toda requisição, o token vai junto num campo da requisição, o header Authorization:
GET /emails
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
O servidor só confere a assinatura, sem consultar banco nenhum. Isso é o que chamam de autenticação stateless.
"Bearer" quer dizer "portador": qualquer um que tiver o token pode usar, sem provar mais nada. Ou seja, um JWT vazado vale tanto quanto um cookie de sessão vazado.
E um detalhe interessante: o navegador não anexa o header Authorization sozinho, quem coloca é o código da aplicação. Por isso, um JWT enviado nesse header não sofre CSRF do jeito que o cookie sofre.
Por dentro do token
Por dentro, o token tem 3 partes separadas por ponto:
- Header: como foi assinado (o algoritmo).
- Payload: os dados, como o seu id, a sua permissão e quando expira.
- Signature: a assinatura.
Este é o token da imagem:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3OTI1MzcyMDB9.5D9T1LnD3PpUHNJxm8pq_sny42svPgUxKkkpA8ZSnsQ
Decodificando as duas primeiras partes, sem chave nenhuma, dá:
// Header
{
"alg": "HS256", // como foi assinado
"typ": "JWT" // é um JWT
}
// Payload
{
"sub": "123", // quem é você (id 123)
"role": "admin", // a permissão, criada pela aplicação
"exp": 1792537200 // quando expira, em segundos desde 1970
}
Repare: Header e Payload são só base64url, uma codificação, não criptografia. Qualquer um lê. Dá pra testar no terminal:
echo 'eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3OTI1MzcyMDB9' | base64 -d
# {"sub":"123","role":"admin","exp":1792537200}
Esse comando só funciona direto aqui porque esse trecho não precisa de padding. Em geral, troque
-_por+/e complete com=antes de decodificar.
Ou seja, a assinatura protege o token contra alteração, não contra leitura. Se alguém trocar "role":"user" por "role":"admin", a assinatura não bate mais e o servidor recusa. Mas nada de colocar segredo no payload.
A analogia do crachá
Pensa num crachá com seu nome, cargo e validade, carimbado pela empresa.
O crachá é o JWT e o carimbo é a assinatura: qualquer porteiro confere e libera, sem ligar pra ninguém. O problema é que o porteiro não tem como saber se você foi demitido ontem.
O problema central do JWT: ele vale até expirar
Esse é o ponto principal: o JWT vale até expirar, não importa o quê. O servidor não guarda nada sobre os tokens que emitiu, então não tem o que apagar.
- Clicou em "Sair"? O token continua valendo. É o 200 da imagem lá de cima.
- Tirou o admin de alguém? O token antigo segue dizendo
"role":"admin"até vencer. - Detectou uma invasão? O token do atacante também segue válido.
As mitigações (e o custo delas)
Dá pra contornar, mas cada saída tem um preço:
- Token curto + refresh token: o token que vai em toda requisição (o access token) vive pouco. Pra pegar outro, o cliente usa um refresh token, um segundo token que, pela RFC 6749 (OAuth 2.0), serve só pra pedir novos access tokens.
- Lista de bloqueio (denylist): guardar os tokens revogados e conferir em toda requisição.
Mas aí o servidor volta a guardar estado: precisa guardar os refresh tokens, ou consultar a lista de bloqueio a cada requisição. A própria OWASP diz que, se você usa JWT como sessão, vai precisar de um jeito de invalidar, e aí a sessão deixa de ser stateless, o que pode acabar com a vantagem.
Onde guardar o JWT no navegador?
Aqui aparece um dilema que a sessão não tem:
- localStorage: o JavaScript lê à vontade. A OWASP recomenda não guardar tokens de autenticação ali, porque um único XSS vaza todos eles.
- Cookie HttpOnly: protege contra XSS, mas aí o navegador manda o cookie sozinho e você volta a ter CSRF. Na prática, vira um cookie de sessão mais pesado.
Um risco extra: quem escolhe o algoritmo?
Lembra do header, que diz o algoritmo? Ele vem do cliente, e ainda não foi validado quando o servidor lê ele. Se a biblioteca confia no alg, é o atacante quem escolhe como a assinatura vai ser verificada.
Em 2015, o pesquisador Tim McLean achou bibliotecas de JWT aceitando "alg":"none", ou seja, um token sem assinatura sendo tratado como se a assinatura tivesse sido verificada. O ataque é trocar o header, mudar o payload pro que quiser e deixar a assinatura vazia:
// Header forjado
{ "alg": "none", "typ": "JWT" }
// Payload forjado
{ "sub": "123", "role": "admin" }
No pacote jsonwebtoken (Node.js), um erro parecido, na validação do tipo de algoritmo (aceitar HS256 quando esperava RS256), virou a falha CVE-2015-9235, com nota 9.8 de 10 no NVD. As versões 4.2.1 e anteriores eram afetadas, e a correção veio na 4.2.2.
A defesa: o servidor fixa o algoritmo. A RFC 8725 (boas práticas de JWT) pede que a biblioteca deixe você dizer quais algoritmos aceita, e não use nenhum outro. De forma simplificada:
// pseudocódigo
// errado: deixa o token escolher o algoritmo
verificar(token, chave)
// certo: o servidor já sabe qual algoritmo usa
verificar(token, chave, { algoritmos: ["HS256"] })
Com sessão esse problema não existe: o servidor só compara um ID aleatório, não tem algoritmo pra escolher errado.
Quando usar cada um?
Não é regra, cada caso é um caso. Mas uma pergunta ajuda: quem é o cliente?
Navegador falando com o seu backend: normalmente sessão. O navegador já manda o cookie sozinho, o HttpOnly protege contra XSS, o logout vale na hora e o CSRF tem defesa conhecida. A própria Auth0 indica cookie nesse cenário: quando o frontend só chama uma API num domínio que compartilha cookie com ele, não precisa de token nenhum.
App mobile, outro serviço ou SSO: normalmente JWT.
-
App mobile: não tem o cookie automático do navegador, então um token curto no
Authorization+ refresh token é o caminho comum. - Outro serviço: um serviço confere a assinatura do token sozinho, sem precisar chamar ninguém.
- SSO (Single Sign-On, logar uma vez e entrar em vários sistemas): no OpenID Connect, o ID Token, que diz quem é o usuário, é um JWT pela própria especificação.
E dá até pra usar os dois na mesma aplicação: sessão pro navegador, JWT curto pro app e pros serviços. Foi por isso que, numa aplicação web, a resposta pro tech lead foi sessão.
Resumindo
- O HTTP esquece você a cada requisição.
- Sessão: o servidor guarda, o cookie leva só um número.
- JWT: os dados vão no token, assinados e legíveis.
- Sessão sai na hora, JWT vale até expirar.
- Em geral, navegador: sessão. Mobile, serviços e SSO: JWT.
Conclusão
Apresentei apenas algo introdutório, recomendo se aprofundarem! Cada caso é um caso, e não existe vencedor absoluto: cada um é certo pro seu tipo de cliente.
Pra quem já é experiente: no seu último projeto vocês foram de sessão ou JWT, e por quê? Conta nos comentários.
Obrigado por ler até aqui!
Esse artigo nasceu de uma thread que publiquei no X. Se preferir a versão curta, com os diagramas, é só dar uma olhada lá.
Fontes
- OWASP: Session Management Cheat Sheet
- MDN: Using HTTP cookies
- PortSwigger: CSRF
- OWASP: Cross-Site Request Forgery Prevention Cheat Sheet
- RFC 7519: JSON Web Token (JWT)
- jwt.io: Introduction to JSON Web Tokens
- OWASP: JSON Web Token Cheat Sheet
- Stop using JWT for sessions (Sven Slootweg)
- RFC 6749: OAuth 2.0 (refresh token)
- Auth0: Refresh Token Rotation
- RFC 6750: Bearer Token Usage
- Auth0: Critical vulnerabilities in JSON Web Token libraries (Tim McLean)
- NVD: CVE-2015-9235
- GitHub Advisory: GHSA-c7hr-j4mj-j2w6 (correção na 4.2.2)
- RFC 8725: JWT Best Current Practices
- Auth0: Token Storage
- OpenID Connect Core 1.0 (SSO e ID Token)





Top comments (0)