JWT ou Cookies no ASP.NET Core? Depende
Sua API RESTful tá linda. Endpoints no plural, verbos HTTP corretos, `ProblemDetails` pra erro, `GET` que não altera estado, `DELETE` idempotente. Parabéns, agora vamos falar de autenticação
Me responde: como você autentica?
Jogou um [Authorize] na controller e colou um trecho de JWT do ChatGPT? Colou o token no localStorage porque "funcionou"? Se sim, respira fundo.
"Usar JWT" virou o default do universo da programação sem que ninguém parasse pra perguntar por quê. Nem todo projeto precisa de JWT. Alguns precisam e mesmo assim deveriam considerar cookies. Essa escolha não é preferência pessoal, é arquitetura. E arquitetura errada dói em produção.
O que estamos comparando
Antes de meter a mão no código, vamos alinhar o vocabulário.
JWT Bearer Authentication: o servidor gera um token JWT, devolve pro client, e o client envia esse token no header Authorization: Bearer <token> a cada request. O servidor valida sem precisar de estado. Stateless por design.
Cookie Authentication: o servidor cria um cookie e é o browser quem vai gerenciar, enviando automaticamente a cada request pro mesmo domínio.
No ASP.NET Core, ambos são AuthenticationScheme. Ambos usam o mesmo middleware. Ambos decoram endpoints com [Authorize]. A diferença tá no transporte, no storage e na revogação. É aí que a coisa fica interessante.
OAuth2 e OpenID Connect ficam de fora. Já têm cobertura dedicada. Aqui é mão na massa.
JWT Bearer Authentication
Vamos lá. JWT Bearer é o que 90% dos tutoriais ensinam. Pra ser justo, tem motivo: funciona bem em cenários distribuídos.
O fluxo é direto:
- Client envia credenciais (login + senha) pra um endpoint de autenticação
- Servidor valida, gera um JWT assinado com claims do usuário
- Client recebe o token e envia no header
Authorizationa cada request - Servidor valida a assinatura e as claims, sem consultar banco, sem session store
Configuração no ASP.NET Core
// Program.cs — .NET 8+
var builder = WebApplication.CreateBuilder(args);
var jwtKey = new SymmetricSecurityKey(
RandomNumberGenerator.GetBytes(32)); // 256 bits — RNG criptográfico
builder.Services.AddSingleton(jwtKey);
builder.Services.AddAuthentication("Bearer")
.AddJwtBearer("Bearer", options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true,
ValidIssuer = builder.Configuration["Jwt:Issuer"],
ValidAudience = builder.Configuration["Jwt:Audience"],
IssuerSigningKey = jwtKey,
ClockSkew = TimeSpan.Zero
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
E o endpoint de login que gera o token:
app.MapPost("/auth/login", (LoginRequest request, IConfiguration config,
SymmetricSecurityKey jwtKey) =>
{
// Validação real aqui — banco, Identity, o que for
if (request.Username != "bruno" || request.Password != "senha-segura")
return Results.Unauthorized();
var claims = new[]
{
new Claim(ClaimTypes.Name, request.Username),
new Claim(ClaimTypes.Role, "Admin"),
new Claim("department", "Engineering")
};
var credentials = new SigningCredentials(jwtKey, SecurityAlgorithms.HmacSha256);
var token = new JwtSecurityToken(
issuer: config["Jwt:Issuer"],
audience: config["Jwt:Audience"],
claims: claims,
expires: DateTime.UtcNow.AddMinutes(30),
signingCredentials: credentials);
return Results.Ok(new
{
token = new JwtSecurityTokenHandler().WriteToken(token)
});
});
Esse código roda. Cola no Program.cs e funciona.
Um detalhe que parece bobo mas não é: a chave de assinatura. Metade dos tutoriais por aí pega uma string do appsettings.json e converte com Encoding.UTF8.GetBytes(). Isso é derivação de chave amadora. A entropia da sua chave fica limitada ao que alguém digitou — e "minha-chave-super-secreta-123" não tem 256 bits de entropia, tem entropia de senha humana. RandomNumberGenerator.GetBytes(32) gera 256 bits uniformes direto do CSPRNG do sistema operacional. É o que o .NET usa internamente no Data Protection, no ASP.NET Identity, em tudo que é sério. Se sua chave de assinatura não veio de um RNG criptográfico, ela não é uma chave, é uma senha disfarçada.
Em produção, você gera a chave uma vez com RandomNumberGenerator, persiste num secret store (Azure Key Vault, AWS Secrets Manager, até User Secrets em dev) e carrega no startup. Se a chave morre com o processo, todo token emitido morre junto. Geração via RNG e persistência segura são coisas separadas, resolva as duas.
O que funciona bem
O token carrega tudo. O servidor não precisa de session store, então escala horizontal fica trivial, qualquer instância valida o token. Funciona em SPA, mobile, microservices, API pública. O token viaja no header, não depende de browser, não tem problema de domínio.
O que machuca
Eu já implementei JWT numa aplicação que era monolítica e server-rendered. Razor Pages, tudo no mesmo domínio, um servidor só. Resultado? Complexidade de refresh token que cookie resolvia de graça. Na prática, eu resolvi um problema que não existia.
O token é auto-contido. Uma vez emitido, é válido até expirar. Usuário trocou a senha? Token antigo ainda funciona. Precisa de blocklist no servidor? Parabéns, você acabou de reintroduzir estado no seu sistema "stateless".
Storage no client é outra dor de cabeça. localStorage? XSS leva o token. Cookie HttpOnly? Aí você tá usando cookie pra transportar JWT, e perde metade da vantagem. E o payload é legível. Base64 não é criptografia. Qualquer pessoa decodifica e lê as claims. Não coloque dados sensíveis ali. Nunca.
Você vai dizer que JWT escala sem estado no servidor. Sim. Mas e quando o usuário troca a senha e os tokens antigos ainda são válidos por 30 minutos? E quando você precisa de refresh token, que precisa de um endpoint, que precisa de storage seguro no client, que precisa de rotação? O "stateless" virou um ecossistema inteiro de complexidade.
Cookie Authentication
Agora o outro lado. Cookie Authentication é o veterano. Existe desde que a web existe. E no ASP.NET Core, funciona com uma simplicidade que muita gente ignora porque "JWT é mais moderno". Moderno não quer dizer melhor (e eu aprendi isso da forma cara).
O fluxo:
- Client envia credenciais pro endpoint de login
- Servidor valida, cria um
ClaimsPrincipal, chamaHttpContext.SignInAsync() - O middleware serializa as claims num cookie
HttpOnly,Secure,SameSite=Strict - O browser envia o cookie automaticamente a cada request pro mesmo domínio
- Logout?
HttpContext.SignOutAsync(). Morreu.
Configuração no ASP.NET Core
// Program.cs — .NET 8+
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication("Cookies")
.AddCookie("Cookies", options =>
{
options.LoginPath = "/auth/login";
options.LogoutPath = "/auth/logout";
options.ExpireTimeSpan = TimeSpan.FromHours(8);
options.SlidingExpiration = true;
options.Cookie.HttpOnly = true;
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
options.Cookie.SameSite = SameSiteMode.Strict;
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
Login e logout:
app.MapPost("/auth/login", async (LoginRequest request, HttpContext context) =>
{
// Validação real aqui — banco, Identity, o que for
if (request.Username != "bruno" || request.Password != "senha-segura")
return Results.Unauthorized();
var claims = new[]
{
new Claim(ClaimTypes.Name, request.Username),
new Claim(ClaimTypes.Role, "Admin"),
new Claim("department", "Engineering")
};
var identity = new ClaimsIdentity(claims, "Cookies");
var principal = new ClaimsPrincipal(identity);
await context.SignInAsync("Cookies", principal);
return Results.Ok();
});
app.MapPost("/auth/logout", async (HttpContext context) =>
{
await context.SignOutAsync("Cookies");
return Results.Ok();
});
Perceba a diferença: não tem JwtSecurityTokenHandler, não tem SigningCredentials, não tem token pra devolver. O middleware cuida de tudo. O browser cuida do resto.
O que funciona bem
Revogação é trivial. SignOutAsync() e acabou. Quer invalidar server-side? Muda o DataProtection key ou implemente validação customizada no OnValidatePrincipal. Sem blocklist, sem dança.
HttpOnly impede JavaScript de ler o cookie. XSS não leva a credencial. SameSite=Strict impede que o cookie viaje em requests cross-origin, o que já mitiga CSRF. Se precisar de mais, AddAntiforgery() resolve. E o sliding expiration renova o cookie automaticamente enquanto o usuário tá ativo. Sem refresh token, sem rotação, sem endpoint extra.
O que machuca
Cookie funciona lindamente quando tudo roda no mesmo domínio. Aí você adiciona um app mobile e o castelo de cartas cai.
SPA em app.exemplo.com consumindo API em api.exemplo.com? Precisa de SameSite=None, Secure, configuração CORS cirúrgica. Funciona, mas a complexidade cresce. Apps nativos (iOS, Android) não gerenciam cookies como um browser. Você acaba montando uma camada extra pra simular o comportamento, e nesse ponto JWT é mais simples. E se o cookie depende de estado no servidor (session, data protection keys compartilhadas), escala horizontal exige Redis ou SQL Server distribuído.
Comparação direta
Na prática, a escolha resume esses trade-offs:
| Critério | JWT Bearer | Cookie Auth |
|---|---|---|
| Stateless | Sim, token auto-contido | Depende, pode ser stateful com session store |
| Cross-origin | Funciona nativamente via header | Requer CORS + SameSite=None + Secure |
| Mobile nativo | Ideal, header Authorization |
Complexo, sem gerenciamento nativo de cookies |
| Revogação | Difícil, blocklist + refresh token | Trivial, SignOutAsync() + invalidação server-side |
| XSS risk | Alto se localStorage |
Baixo com HttpOnly |
| CSRF risk | Baixo, token no header, não é enviado automaticamente | Médio, mitigado com SameSite + antiforgery |
| Complexidade | Média, refresh token + rotação + storage | Baixa, browser + middleware cuidam de quase tudo |
| Escala horizontal | Fácil, qualquer instância valida | Requer data protection keys distribuídas |
Nenhum ganha em tudo. Depende do contexto. Sempre dependeu.
Quando usar qual
Direto ao ponto.
Se você tem uma SPA consumindo uma API separada (Angular, React, Vue + API .NET), vai de JWT Bearer. O token viaja no header Authorization, cross-origin funciona, o frontend gerencia o ciclo de vida. E guarde o token num cookie HttpOnly. Sim, cookie transportando JWT. Parece irônico, mas é a combinação mais segura: você ganha o stateless do JWT com o HttpOnly do cookie.
Se sua aplicação é monolítica e server-rendered (Razor Pages, MVC, Blazor Server), vai de Cookie Auth. Tudo roda no mesmo domínio, o browser gerencia o cookie, revogação é trivial, sliding expiration funciona de graça. Não complique o que é simples.
Mobile nativo consumindo API .NET? JWT Bearer. Sem browser, sem cookie management. O token vai no header, qualquer HttpClient manda.
Microservices com API Gateway? JWT Bearer. O token é portátil, cada serviço valida independentemente, sem acoplamento a session store.
E quando o projeto é híbrido (app server-rendered com SPA parcial, dashboard admin + API pública)? Usa os dois. Configura os dois AuthenticationScheme no mesmo projeto:
builder.Services.AddAuthentication()
.AddCookie("Cookies", options => { /* ... */ })
.AddJwtBearer("Bearer", options => { /* ... */ });
// Nos endpoints:
app.MapGet("/dashboard", () => "admin")
.RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = "Cookies" });
app.MapGet("/api/data", () => "json")
.RequireAuthorization(new AuthorizeAttribute { AuthenticationSchemes = "Bearer" });
Na teoria, escolher é simples. Na prática, 80% dos projetos começam monolíticos e viram distribuídos. Eu já vi time começar com cookie, crescer pra microservices, e ter que migrar pra JWT no meio do caminho, com usuários logados. Não é bonito. Pense no futuro, mas não over-engineer o presente. Comece com o que resolve hoje.
Conclusão
Sua API já tem endpoints corretos e verbos no lugar certo. Agora sabe como escolher a autenticação também.
JWT Bearer pra cenários distribuídos. Cookie Auth pra cenários server-rendered no mesmo domínio. Projeto híbrido? O ASP.NET Core te deixa usar os dois.