DEV Community

Stephanto
Stephanto

Posted on

JWT ou sessão? Como cada um funciona e quando usar qual

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.

O mesmo clique em Sair: na sessão dá 401, fora na hora; no JWT dá 200, ainda dentro até expirar

É 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.

Ciclo da sessão: login, servidor guarda abc123 = Vinicius, Set-Cookie, e a cada requisição o servidor pergunta ao armazenamento quem é abc123

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=/
Enter fullscreen mode Exit fullscreen mode

E a partir daí, em toda requisição:

GET /emails
Cookie: session_id=abc123
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

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. Strict nunca vai; Lax vai também quando o usuário navega pro seu site vindo de outro (clicou num link); None vai sempre (e exige Secure).

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=/
Enter fullscreen mode Exit fullscreen mode

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...
Enter fullscreen mode Exit fullscreen mode

O servidor só confere a assinatura, sem consultar banco nenhum. Isso é o que chamam de autenticação stateless.

Ciclo do JWT: login, servidor assina o token com a chave secreta, e a cada requisição só confere a assinatura, sem consultar banco

"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.

JWT: 3 partes separadas por ponto, header, payload e signature, com cada trecho decodificado

Este é o token da imagem:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJyb2xlIjoiYWRtaW4iLCJleHAiOjE3OTI1MzcyMDB9.5D9T1LnD3PpUHNJxm8pq_sny42svPgUxKkkpA8ZSnsQ
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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}
Enter fullscreen mode Exit fullscreen mode

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" }
Enter fullscreen mode Exit fullscreen mode

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"] })
Enter fullscreen mode Exit fullscreen mode

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?

Quem é o cliente? Navegador falando com o seu backend: normalmente sessão. App mobile, outro serviço ou SSO: normalmente JWT

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

Top comments (0)