JWT ou Cookies no ASP.NET Core? Depende

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

19 de maio de 2026

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:

  1. Client envia credenciais (login + senha) pra um endpoint de autenticação
  2. Servidor valida, gera um JWT assinado com claims do usuário
  3. Client recebe o token e envia no header Authorization a cada request
  4. 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.

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:

  1. Client envia credenciais pro endpoint de login
  2. Servidor valida, cria um ClaimsPrincipal, chama HttpContext.SignInAsync()
  3. O middleware serializa as claims num cookie HttpOnly, Secure, SameSite=Strict
  4. O browser envia o cookie automaticamente a cada request pro mesmo domínio
  5. 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.

Confira mais:

Fique por dentro das novidades

Assine nossa newsletter e receba as últimas atualizações e artigos diretamente em seu email.

Assinar gratuitamente