Cookies de Sessão ou Bearer Tokens?

On this page
Algumas semanas atrás, peguei um ticket que dizia, mais ou menos, "implementar a camada de autenticação". A aplicação já tinha a autenticação funcionando, então a primeira tarefa não foi escrever código, foi ler o que já estava lá.
O que eu encontrei: no login, a API emitia um access token, o frontend o salvava no localStorage, e cada requisição saía com Authorization: Bearer . Se você construiu uma SPA nos últimos dez anos, esta é provavelmente a imagem padrão na sua cabeça também. Não está errado. Apenas não parecia a melhor opção para esta aplicação, e a tabela de guards do AdonisJS aponta para outro lugar para um caso como o nosso, quando você realmente a lê em vez de ignorá-la.
Se você quiser a mecânica do lado do navegador sobre como Web Storage e Set-Cookie realmente diferem, Alencar já cobriu esse terreno em LocalStorage and Cookies under the hoodies. Este post começa um nível acima: escolher entre cookies de sessão e bearer tokens no AdonisJS, e o que cada escolha custa depois.
Antes de começarmos. Este não é um tutorial sobre como configurar uma camada de autenticação "do jeito certo", e eu não sou um especialista em segurança. É o relato de uma decisão que tive que tomar em uma aplicação real, as compensações que ponderei e o que eu gostaria de verificar antes de tomar a mesma decisão novamente. Leia como um ponto de partida para sua própria pesquisa, não como um checklist de segurança. Se você está construindo algo onde errar nisso realmente prejudica, fale com alguém que trabalha com segurança.
O que já estava lá
O AdonisJS traz três guards por padrão:
basic_auth
O app estava no tokens guard, configurado da maneira usual:
// config/auth.ts
import { defineConfig } from '@adonisjs/auth'
import { tokensGuard, tokensUserProvider } from '@adonisjs/auth/access_tokens'
export default defineConfig({
default: 'api',
guards: {
api: tokensGuard({
provider: tokensUserProvider({
tokens: 'accessTokens',
model: () => import('#models/user'),
}),
}),
},
})E o endpoint de login:
// app/controllers/session_controller.ts
async store({ request, auth }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
return await auth.use('api').createToken(user)
}Antes de eu dizer qualquer coisa crítica, crédito a quem merece: esta é uma implementação de token sólida, e o tokens guard faz mais do que as pessoas reconhecem (User.verifyCredentials por si só já vale uma leitura própria). Os tokens são opacos, não JWTs. Eles são strings aleatórias, armazenadas no seu banco de dados como hashes, verificadas comparando hashes. Isso significa que você pode revogar um instantaneamente deletando uma linha, que é exatamente a coisa que você não pode fazer com um JWT até que ele expire por conta própria. Cada token carrega habilidades (projects:read, admin, *), um expiresIn opcional, e uma coluna last_used_at que o guard atualiza em cada requisição autenticada. O valor público recebe até um prefixo oat_ e um checksum para que scanners de segredos possam detectá-lo se ele vazar em um repo.
Nada disso foi o que me incomodou. O que me incomodou foi a última milha: onde o navegador o guarda.
Onde os tokens realmente pertencem
Deixe-me ser justo com a abordagem antes de me afastar dela. Access tokens são a escolha certa quando o cliente não pode usar cookies, ou não deveria:
- Apps nativos para mobile e desktop. Não há nenhum pote de cookies para você se preocupar aqui.
- Ferramentas de CLI e integrações server-to-server. Uma credencial de longa duração armazenada pela máquina é exatamente o objetivo.
- Acesso a APIs de terceiros. Você quer permissões por token e um botão de revogar em uma tela de configurações.
- Um frontend em um site genuinamente diferente da sua API. Mais sobre isso abaixo, é onde tudo muda.
Se qualquer um desses descreve seu cliente, você provavelmente pode parar de ler e manter seus tokens. O restante deste post é sobre o caso onde eles não descrevem.
Onde dói no navegador
Nosso cliente era um SPA, servido do mesmo site que a API, sem falar com mais nada. E nessa configuração localStorage tem um problema específico e bem conhecido: qualquer coisa que execute JavaScript na sua página pode lê-lo. Não um atacante hipotético com acesso ao banco de dados. Uma dependência npm comprometida. Um script de analytics de terceiros. Um XSS refletido em um formulário que você esqueceu de escapar.
E o que eles conseguem é pior do que uma sessão sequestrada. Eles conseguem um bearer token, uma credencial portátil que funciona de qualquer lugar. Copie-o, use-o de um laptop em outro país, continue usando-o até que ele expire. Nada na requisição diz "isso deve vir do navegador da vítima".
Enquanto isso, a estrutura custa algo todos os dias:
- Você mantém o header em cada requisição, em cada cliente, para sempre.
- O tempo de vida da credencial é gerenciado por lógica de refresh manual, tratamento de expiração, "por que estou deslogado nesta aba mas não naquela".
- O logout é
localStorage.removeItem()a menos que você também lembre de invalidar no servidor.
A documentação deixa a responsabilidade clara: a aplicação cliente é responsável por armazenar tokens de forma segura. Em um navegador, "segura" é uma palavra que carrega muito peso nessa frase.
Cross-origin não é cross-site
Antes de prosseguir, há uma distinção que decide qual guard está disponível para você, e é fácil confundi-las:
app.example.com→api.example.comé cross-origin, mas same-site. Você precisa de CORS. Seu cookieSameSite=Laxcontinua fluindo. Sessões são uma opção realista aqui.my-app.vercel.app→api.example.comé cross-site. Agora seu cookie de sessão é um cookie de terceiros. Você precisa deSameSite=None; Securee, a partir daí, está sujeito ao que cada navegador pensa atualmente sobre cookies de terceiros. Isso não é um problema de configuração que você possa resolver – é uma política da qual você é convidado.
É por isso que a tabela de guards do AdonisJS recomenda sessões para apps renderizados no servidor e SPAs no mesmo domínio de nível superior, e access tokens para uma SPA em um domínio diferente. Lendo dessa forma, parece menos uma preferência de gosto e mais uma restrição de navegador disfarçada de recomendação.
A mudança: o session guard
A troca em si é quase entediante, que é a parte boa. O guia do session guard cobre isso de ponta a ponta; aqui está a versão curta.
node ace add @adonisjs/auth --guard=session// config/auth.ts
import { defineConfig } from '@adonisjs/auth'
import { sessionGuard, sessionUserProvider } from '@adonisjs/auth/session'
export default defineConfig({
default: 'web',
guards: {
web: sessionGuard({
useRememberMeTokens: false,
provider: sessionUserProvider({
model: () => import('#models/user'),
}),
}),
},
})// app/controllers/session_controller.ts
async store({ request, auth, response }: HttpContext) {
const { email, password } = request.only(['email', 'password'])
const user = await User.verifyCredentials(email, password)
await auth.use('web').login(user)
return response.noContent()
}
async destroy({ auth, response }: HttpContext) {
await auth.use('web').logout()
return response.noContent()
}As rotas são protegidas da mesma forma que antes, com middleware.auth(). O controller mal mudou. O que mudou foi o formato da credencial: o navegador agora guarda um ID de sessão em um cookie, e a identidade em si vive no seu session store, do seu lado da rede.
Uma coisa que você ganha de graça aqui e deveria saber de qualquer forma: session fixation. Quando um usuário faz login, o ID da sessão precisa ser regenerado; caso contrário, um ID que um invasor plantou antes do login permanece válido depois dele. O pacote de auth faz isso por você. Se você fizer o login manualmente, precisará chamar session.regenerate() você mesmo.
As configurações de cookie que importaram
Este é o arquivo ao qual continuei voltando, e é fácil ignorá-lo porque os padrões já são sensatos:
// config/session.ts
export default defineConfig({
age: '2h',
cookie: {
path: '/',
httpOnly: true, // document.cookie can't touch it
secure: app.inProduction, // HTTPS only where it counts
sameSite: 'lax', // the CSRF baseline
},
store: env.get('SESSION_DRIVER'),
// ...stores
})Quatro linhas, três decisões reais:
httpOnly: trueé a razão de estarmos aqui. O ID da sessão é invisível para o JavaScript, então um XSS não pode exfiltrá-lo.securetem como padrãoapp.inProductionpara que o desenvolvimento HTTP local continue funcionando. Eu o deixei exatamente como veio.sameSitedecide quando o navegador está disposto a anexar o cookie a uma requisição cross-site.laxé o que vem por padrão e o que mantivemos;stricté mais rigoroso, mas pode quebrar um fluxo que você precise;nonerequersecure: truee significa "anexe sempre".
Depois, escolha seu store deliberadamente. O driver cookie mantém os dados da sessão em um cookie criptografado e trunca silenciosamente qualquer coisa acima de ~4KB — tudo bem enquanto sua sessão é pequena, fácil de ter problemas quando não for mais. file funciona bem em uma única máquina. Quando você tiver mais de um servidor, será redis ou database, e isso é um item real de infraestrutura que você não tinha com tokens.
Dois padrões de CORS que valem a pena conhecer
Os cookies só chegam à sua API se o navegador estiver convencido de que tem permissão para enviá-los e, por padrão, ele não tem. A mecânica geral é melhor coberta pelo MDN e pelo guia de CORS do que por mim. Duas coisas são específicas do Adonis e vale a pena ter em mente antes de você encarar um 401 sem cookie anexado:
- O padrão é
origin: app.inDev ? true : ['https://app.example.com']. Totalmente aberto localmente, fechado em produção. Esse é um bom padrão, e também exatamente a coisa que vai "quebrar" seu primeiro deploy. Não está quebrado, está esperando você nomear seu frontend. origin: '*'ecredentials: truesão incompatíveis. Os navegadores rejeitam a combinação sumariamente, então o Adonis reflete a origem solicitante de volta em vez de emitir um*literal. Útil de saber; não é uma estratégia. Liste suas origens. “`ts
// config/cors.ts
export default defineConfig({
enabled: true,
origin: app.inDev ? true : [‘https://app.example.com‘]
credentials: true, // sends Access-Control-Allow-Credentials
maxAge: 90,
})
O restante é um aperto de mão de dois lados: credentials: true no servidor, credentials: 'include' no cliente. Esqueça qualquer um dos lados e o cookie silenciosamente não viaja.
Nosso frontend é React com Vite e TanStack, falando com o Adonis através do Tuyau, então a metade do cliente é uma opção no momento em que o cliente é criado:
export const tuyau = createTuyau({
baseUrl: env.VITE_API_URL,
registry,
credentials: 'include',
})Vale saber se você está na mesma stack: com credentials: 'include' configurado, o Tuyau cuida do CSRF para você – a ida e volta de ler o cookie e ecoá-lo em um cabeçalho que, de outra forma, você escreveria manualmente em um wrapper de fetch.
Em desenvolvimento, nosso baseUrl aponta direto para a porta do Adonis, e o padrão de dev acima permite que isso passe sem que nada disso importe. O que é outra maneira de dizer que o desenvolvimento nunca testa realmente esta parte.
O que eu troquei
Mover a credencial para fora do alcance do JavaScript não deleta o risco, ele o realoca. Sendo honesto sobre o novo:
Bearer tokens são imunes a CSRF. O navegador nunca anexa um cabeçalho Authorization por conta própria — seu código faz isso. Um cookie é o oposto: o navegador o anexa automaticamente a requisições destinadas ao seu domínio, incluindo as iniciadas por uma página que você não escreveu. Portanto, no momento em que você muda para cookies, o CSRF se torna seu problema.
As respostas são conhecidas e baratas:
sameSite: 'lax'bloqueia o POST de formulário cross-site clássico e cobre a maior parte dele.@adonisjs/shieldadiciona proteção CSRF real, comenableXsrfCookiepara o fluxo de SPA onde o cliente lê um token e o replica em um cabeçalho.- Defina o escopo do seu
exceptRoutesde forma estreita. Cada exceção é um buraco que você perfurou de propósito.
E o que recebi em troca, além da história do XSS: o logout tornou-se real. Destruir a sessão a invalida no lado do servidor, em todos os lugares, imediatamente. Sem o "delete a chave e torça".
Side by side
| — | ||||||
|---|---|---|---|---|---|---|
| Onde a credencial reside | Armazenamento JS do cliente | Store do servidor; navegador mantém apenas um ID | ||||
| Leitura pelo JavaScript da página | Sim | Não | ||||
| Pior caso sob XSS | Token roubado, replicado fora do dispositivo | Atacante age apenas dentro do navegador da vítima | ||||
| Exposição a CSRF | Nenhuma por padrão | Real — precisa de SameSite + shield | ||||
| Frontend em um site diferente | Funciona | Território de cookies de terceiros | ||||
| Mobile / CLI / clientes de terceiros | Funciona | Não funciona | ||||
| Revogação | Deletar a linha do token | Destruir a sessão | ||||
| Infraestrutura extra | auth_access_tokens |
table Session store (Redis em produção)| | Permissões por credencial | | Habilidades, expiração, nomes | | Não nativo | |
Lançando sem deslogar todo mundo
Aqui está a parte que a configuração não te conta. A aplicação já tinha usuários navegando com tokens em seus browsers. Alterar o guard padrão invalida cada um deles no mesmo instante.
O AdonisJS torna a saída quase banal: registre ambos os guards e deixe que a rota aceite qualquer um dos dois.
router
.get('projects', [ProjectsController])
.use(middleware.auth({ guards: ['web', 'api'] }))A autenticação tem sucesso se qualquer um dos guards tiver sucesso. Então, por um tempo, ambas as coisas foram verdade ao mesmo tempo. O frontend começou a emitir sessões no momento em que foi implantado, e qualquer pessoa que ainda tivesse um token continuou funcionando exatamente como antes, nas mesmas rotas, sem nenhum branch no código para manter.
Ninguém foi forçado. Os usuários migraram para sessões passivamente, em seu próximo login. Deixamos isso rodar por uma semana com base em nossos registros e então excluímos a tabela auth_access_tokens.
Para as migrações, foi algo sem incidentes. Alguém certamente foi deslogado em algum lugar naquela semana; era um custo que havíamos aceitado ao começar, e ninguém relatou isso.
E esse mecanismo sobrevive à migração. Registrar dois guards não é apenas um truque de transição; é o que permite que um único codebase sirva um cliente de browser com cookies e um cliente de máquina com tokens, nas mesmas rotas, sem bifurcação na sua lógica de autorização. Precisamos disso por sete dias. A junção permanece lá de graça.
Meus pensamentos após fazer a migração
Nenhum destes é "o seguro". Eles falham de formas diferentes, e você escolhe com base em quem está detendo a credencial.
Para nós, um cliente de browser, nosso próprio frontend, sem outros consumidores; o session guard foi a melhor escolha, e o Adonis tornou isso um diff pequeno: uma troca de guard, cookies httpOnly, CORS com credentials, a questão do CSRF respondida honestamente. Para um telefone, um CLI, o servidor de um parceiro ou um frontend hospedado no domínio de outra pessoa, eu usaria tokens e não sentiria que aceitei algo inferior. A implementação opaca, revogável e com escopo de habilidades que o Adonis fornece é boa.
O que levei mais tempo para internalizar é que o framework nunca me pediu para escolher apenas uma vez. Passei um tempo procurando a resposta certa para "sessões ou tokens", e a pergunta útil acabou sendo "certo para qual cliente?", a qual o sistema de guards já foi construído para me permitir responder de mais de uma maneira.
The docs I leaned on
- Authentication: introduction — a tabela de guards, e a única página que eu leria primeiro
- Session guard — login, logout, protegendo rotas, remember me
- Access tokens guard — habilidades, expiração, revogação
- Sessions — opções de cookie e escolha de um store
- CORS — origens, credenciais, preflight
- Securing SSR apps — o pacote shield e CSRF
We want to work with you. Check out our Services page!


