trilha

Fundamentos de Programação/03 - Sistemas Reais/Semana 10 - Rede e HTTP8 min

Sessão vs JWT

Perguntas-guia
  • Sessão em servidor guarda estado; JWT carrega estado. Qual o custo de cada escolha?
  • Como você revoga um JWT antes de ele expirar? (é a pergunta difícil)
  • Onde guardar o token no cliente, e o que cada lugar expõe (XSS vs CSRF)?
  • O que refresh token resolve, e que novo problema ele cria?

Conceito

As duas respostas para "como o servidor sabe quem você é", dado que HTTP não guarda estado (1. Modelo Cliente-Servidor):

Sessão JWT
Quem guarda o estado o servidor (store) o cliente (no token)
O que o cliente carrega um id opaco os dados + assinatura
Verificação por requisição consulta ao store verificar assinatura (só CPU)
Revogação imediata (apague o registro) impossível por design
Escala horizontal precisa de store compartilhado não precisa de nada
Tamanho no fio ~32 bytes centenas de bytes a KB

A troca é clara: sessão paga uma consulta por requisição e ganha revogação imediata; JWT não paga consulta e perde a revogação.

A pergunta difícil: como você revoga um JWT antes de ele expirar?

Você não pode. O token é autocontido e válido enquanto a assinatura confere e o exp não passou. O servidor não tem onde marcar "este não vale mais".

Todas as respostas na prática reintroduzem estado — ou seja, viram uma sessão com passos extras:

Solução O que ela é de fato
exp curto (5–15 min) + refresh token uma sessão, para o refresh token
denylist de tokens revogados um store de sessão, invertido
versão do token no banco, checada por requisição uma consulta por requisição

Conclusão honesta: se você precisa de revogação, você precisa de estado. O JWT apenas muda onde ele fica. Escolher JWT "para não ter estado" e depois adicionar denylist é escolher o pior dos dois.

Onde guardar no cliente, e a troca é XSS contra CSRF:

Local Vulnerável a Observação
localStorage XSS (JS lê e exfiltra) conveniente, e o pior caso é total
cookie HttpOnly Secure SameSite CSRF (JS não lê, mas o navegador envia) XSS não consegue ler o token
memória (variável JS) perdido em cada recarga melhor postura, pior UX

O consenso atual: cookie HttpOnly + Secure + SameSite=Lax é a melhor escolha padrão, porque XSS deixa de conseguir roubar a credencial. CSRF, o problema restante, é mitigado por SameSite mais um token anti-CSRF onde necessário.

Refresh token resolve o problema criado pelo exp curto (o usuário seria deslogado a cada 15 minutos) e cria três novos: uma credencial de vida longa para proteger, a necessidade de rotação (cada uso emite um novo), e detecção de reuso (se um refresh já usado aparece de novo, alguém o roubou — invalide a família inteira).

Na prática

Verificado: o payload de um JWT é legível sem chave nenhuma.

JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...
  payload decodificado SEM chave nenhuma: map[exp:1.8e+09 role:admin sub:42]

Isso é base64url, não criptografia. JWT é assinado (integridade e autenticidade), não cifrado (confidencialidade).

Consequência prática direta: nunca coloque dado sensível num JWT. CPF, e-mail, papel interno, id de outro sistema — tudo isso vai visível para o cliente e para qualquer coisa que logue o token. (Existe JWE para cifrar, e quase ninguém usa.)

Em Go

Não há JWT na biblioteca padrão — a escolha é golang-jwt/jwt/v5. Mas os blocos existem: crypto/hmac, crypto/sha256, encoding/base64.

As duas vulnerabilidades clássicas de JWT, e como Go as evita

1. alg: none. O atacante troca o header para {"alg":"none"}, remove a assinatura e o servidor aceita. Bibliotecas antigas caíam nisso.

2. Confusão de algoritmo (HS256 vs RS256). O servidor usa RSA (chave pública conhecida). O atacante troca alg para HS256 e assina com a chave pública como se fosse segredo HMAC. Se o código escolhe o algoritmo pelo header do token, ele valida.

A defesa, em qualquer linguagem: nunca confie no alg do token. Fixe o algoritmo esperado na validação:

tok, err := jwt.Parse(s, func(t *jwt.Token) (any, error) {
	if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {   // FIXA o método
		return nil, fmt.Errorf("alg inesperado: %v", t.Header["alg"])
	}
	return chaveSecreta, nil
}, jwt.WithValidMethods([]string{"HS256"}))               // e/ou declare aqui

Cookie de sessão correto em Go:

http.SetCookie(w, &http.Cookie{
	Name:     "sid",
	Value:    idAleatorio,          // crypto/rand, nunca math/rand
	Path:     "/",
	HttpOnly: true,                  // JS não lê: mitiga XSS
	Secure:   true,                  // só sobre TLS
	SameSite: http.SameSiteLaxMode,   // mitiga CSRF
	MaxAge:   int(8 * time.Hour / time.Second),
})

O id de sessão precisa vir de crypto/rand, não de math/rand:

b := make([]byte, 32)
rand.Read(b)                                   // crypto/rand
id := base64.RawURLEncoding.EncodeToString(b)

math/rand é previsível — com a semente, um atacante gera os ids dos outros.

Comparação de token em tempo constante: ao comparar segredos, use subtle.ConstantTimeCompare em vez de ==, para não vazar informação por tempo de resposta.

Respostas às perguntas-guia

1. Sessão em servidor guarda estado; JWT carrega estado. Qual o custo de cada escolha?

Sessão: uma consulta ao store por requisição, e um store para operar — em troca de revogação imediata. JWT: nenhuma consulta e escala trivial — em troca de não poder revogar e de tráfego maior.

2. Como você revoga um JWT antes de ele expirar?

Não revoga. As três soluções (exp curto + refresh, denylist, versão no banco) todas reintroduzem estado. Se revogação é requisito, sessão é mais honesta.

Em Go: a denylist costuma virar Redis com TTL igual ao exp restante — que é literalmente um store de sessão.

3. Onde guardar o token no cliente, e o que cada lugar expõe (XSS vs CSRF)?

localStorage expõe a XSS (roubo total). Cookie HttpOnly expõe a CSRF (uso indevido, sem roubo). Memória é a mais segura e perde na recarga.

Em Go: HttpOnly + Secure + SameSite=Lax como padrão, e SameSite=None com Secure quando o front está em outro domínio.

4. O que refresh token resolve, e que novo problema ele cria?

Resolve a expiração curta do access token sem deslogar o usuário. Cria: uma credencial de vida longa para proteger, a necessidade de rotação a cada uso, e detecção de reuso (reuso = roubo → invalide a família).

Trade-offs

Do conceito:

  • Sessão compra revogação e controle; cobra store compartilhado e uma consulta por requisição.
  • JWT compra ausência de consulta e escala; cobra revogação e tráfego.
  • exp curto compra janela pequena de dano e cobra refresh (mais estado).
  • Cookie HttpOnly compra proteção contra roubo por XSS e cobra lidar com CSRF.

Em Go:

  • Sem JWT na stdlib: uma dependência, e uma decisão de segurança na sua mão (fixar o alg).
  • Sessão à mão é ~50 linhas com Redis, e você entende tudo.
  • crypto/rand e subtle.ConstantTimeCompare estão na stdlib — não há motivo para errar esses dois.

Exemplo prático

package main

import (
	"crypto/hmac"
	"crypto/rand"
	"crypto/sha256"
	"crypto/subtle"
	"encoding/base64"
	"encoding/json"
	"fmt"
	"strings"
	"time"
)

// ============ JWT à mão, com stdlib, para ver o que ele É ============
func assinar(claims map[string]any, segredo []byte) string {
	hdr, _ := json.Marshal(map[string]string{"alg": "HS256", "typ": "JWT"})
	pay, _ := json.Marshal(claims)
	corpo := base64.RawURLEncoding.EncodeToString(hdr) + "." +
		base64.RawURLEncoding.EncodeToString(pay)
	m := hmac.New(sha256.New, segredo)
	m.Write([]byte(corpo))
	return corpo + "." + base64.RawURLEncoding.EncodeToString(m.Sum(nil))
}

// Validação CORRETA: o algoritmo é FIXO no código, não lido do token.
func validar(token string, segredo []byte) (map[string]any, error) {
	partes := strings.Split(token, ".")
	if len(partes) != 3 {
		return nil, fmt.Errorf("formato inválido")
	}
	// 1. o alg do header NÃO decide nada — só é conferido
	hdrBytes, err := base64.RawURLEncoding.DecodeString(partes[0])
	if err != nil {
		return nil, err
	}
	var hdr struct{ Alg string }
	json.Unmarshal(hdrBytes, &hdr)
	if hdr.Alg != "HS256" {
		return nil, fmt.Errorf("alg inesperado: %q", hdr.Alg) // barra alg:none e RS256
	}
	// 2. recalcula a assinatura com o algoritmo FIXO
	m := hmac.New(sha256.New, segredo)
	m.Write([]byte(partes[0] + "." + partes[1]))
	esperada := base64.RawURLEncoding.EncodeToString(m.Sum(nil))
	if subtle.ConstantTimeCompare([]byte(esperada), []byte(partes[2])) != 1 {
		return nil, fmt.Errorf("assinatura inválida")
	}
	// 3. só então confia nas claims
	payBytes, _ := base64.RawURLEncoding.DecodeString(partes[1])
	var claims map[string]any
	json.Unmarshal(payBytes, &claims)
	if exp, ok := claims["exp"].(float64); ok && time.Now().Unix() > int64(exp) {
		return nil, fmt.Errorf("expirado")
	}
	return claims, nil
}

// ============ Sessão: id opaco + store ============
type sessao struct {
	UsuarioID int
	Papel     string
	Expira    time.Time
}

type store struct{ dados map[string]sessao }

func novoID() string {
	b := make([]byte, 32)
	rand.Read(b) // crypto/rand, NUNCA math/rand
	return base64.RawURLEncoding.EncodeToString(b)
}

func (s *store) cria(u int, papel string) string {
	id := novoID()
	s.dados[id] = sessao{u, papel, time.Now().Add(8 * time.Hour)}
	return id
}
func (s *store) busca(id string) (sessao, bool) {
	sess, ok := s.dados[id]
	if !ok || time.Now().After(sess.Expira) {
		return sessao{}, false
	}
	return sess, true
}
func (s *store) revoga(id string) { delete(s.dados, id) } // IMEDIATO

func main() {
	segredo := []byte("segredo-de-producao-vem-de-variavel-de-ambiente")

	// ---------- JWT ----------
	tok := assinar(map[string]any{
		"sub":  "42",
		"role": "admin",
		"exp":  time.Now().Add(15 * time.Minute).Unix(),
	}, segredo)

	fmt.Println("--- JWT ---")
	fmt.Printf("token (%d bytes): %s...\n", len(tok), tok[:45])

	// O payload é LEGÍVEL por qualquer um, sem chave:
	pay, _ := base64.RawURLEncoding.DecodeString(strings.Split(tok, ".")[1])
	fmt.Printf("payload lido SEM a chave: %s\n", pay)
	fmt.Println("^ assinado (integridade), NAO cifrado (confidencialidade)")

	c, err := validar(tok, segredo)
	fmt.Printf("validação ok: %v %v\n", c["role"], err)

	// adulterar o payload invalida a assinatura
	p := strings.Split(tok, ".")
	falso, _ := json.Marshal(map[string]any{"sub": "42", "role": "superadmin",
		"exp": time.Now().Add(time.Hour).Unix()})
	adulterado := p[0] + "." + base64.RawURLEncoding.EncodeToString(falso) + "." + p[2]
	_, err = validar(adulterado, segredo)
	fmt.Printf("payload adulterado: %v\n", err)

	// ataque alg:none — barrado porque o alg é fixo no código
	hdrNone, _ := json.Marshal(map[string]string{"alg": "none", "typ": "JWT"})
	semAssinatura := base64.RawURLEncoding.EncodeToString(hdrNone) + "." + p[1] + "."
	_, err = validar(semAssinatura, segredo)
	fmt.Printf("ataque alg:none:    %v\n", err)

	// não há como revogar antes do exp
	fmt.Println("revogar este token agora: impossível sem reintroduzir estado")

	// ---------- Sessão ----------
	fmt.Println("\n--- Sessão ---")
	st := &store{dados: map[string]sessao{}}
	sid := st.cria(42, "admin")
	fmt.Printf("id opaco (%d bytes): %s...\n", len(sid), sid[:20])
	fmt.Println("^ o cliente não descobre NADA a partir dele")

	s1, ok := st.busca(sid)
	fmt.Printf("consulta ao store: papel=%s ok=%v\n", s1.Papel, ok)

	st.revoga(sid)
	_, ok = st.busca(sid)
	fmt.Printf("após revogar:      ok=%v  <- IMEDIATO\n", ok)
}

Relacionado


Parte de Semana 10 - Rede e HTTP · 00 - MOC Fundamentos de Programação

Buscar

Busca por título, seção e texto das notas