← voltar ao índice

· 11 min de leitura · por Tiago

Autenticação em .NET: do básico ao JWT e OIDC

Autenticação é um daqueles temas que todo desenvolvedor precisa dominar, mas que raramente recebe a atenção que merece até que algo dê errado em produção. Não é incomum ver times inteiros implementando login de forma apressada, copiando trechos de tutoriais desatualizados, e só descobrindo os problemas quando um token expira no meio de uma sessão importante ou quando um pentest encontra uma falha grave de segurança.

Neste post vamos destrinchar autenticação no ecossistema .NET de forma prática e aprofundada. O objetivo não é só mostrar "como fazer", mas principalmente explicar o "por quê" de cada decisão: por que usar cookies em vez de JWT, por que refresh tokens existem, por que armazenar token em localStorage é uma péssima ideia, e como escolher a abordagem certa para o seu cenário. Vamos cobrir desde ASP.NET Core Identity com cookies até autenticação stateless com JWT e integração com provedores externos via OAuth2/OpenID Connect.

Autenticação vs. autorização: por que essa distinção importa

Antes de qualquer linha de código, vale reforçar uma confusão clássica. Autenticação responde à pergunta "quem é você?". Autorização responde à pergunta "o que você pode fazer?". São processos diferentes, geralmente sequenciais, e tratá-los como a mesma coisa costuma gerar código confuso e regras de acesso espalhadas pelo sistema sem lógica clara.

Na prática, em ASP.NET Core isso se reflete em dois conceitos separados no pipeline de middlewares: AddAuthentication() configura como a aplicação identifica o usuário (esquema de cookie, JWT Bearer, etc.), enquanto AddAuthorization() configura as políticas e regras que determinam o que esse usuário identificado pode acessar. Entender essa separação evita a armadilha de colocar regras de negócio de autorização dentro do mecanismo de autenticação, o que dificulta manutenção e testes.

Stateful vs. stateless: a decisão que molda toda a arquitetura

Existem, em linhas gerais, duas filosofias de autenticação. A abordagem stateful mantém o estado da sessão no servidor (normalmente via cookie de sessão), e a stateless carrega toda a informação necessária dentro do próprio token, sem exigir consulta a um armazenamento central a cada requisição.

Essa escolha não é só técnica, ela afeta escalabilidade, revogação de acesso e complexidade operacional. Cookies com sessão no servidor são simples de revogar (basta apagar a sessão), mas exigem armazenamento compartilhado (Redis, banco de dados) quando você tem múltiplas instâncias da aplicação. JWT é naturalmente stateless e escala bem horizontalmente, mas revogar um token antes do seu vencimento natural é um problema real que exige soluções adicionais, como listas de bloqueio ou tempos de expiração curtos combinados com refresh tokens.

Autenticação baseada em cookies com ASP.NET Core Identity

Para aplicações web tradicionais, onde o navegador do usuário interage diretamente com o servidor renderizando páginas (Razor Pages, MVC, ou mesmo um backend que serve um SPA no mesmo domínio), autenticação via cookie ainda é a opção mais robusta e simples de configurar corretamente.

O ASP.NET Core Identity já resolve boa parte do trabalho pesado: hash de senha com salt, bloqueio de conta após tentativas falhas, confirmação de e-mail, autenticação em dois fatores, entre outros. Um setup básico se parece com isto:

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDbContext<AppDbContext>(options =>
    options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

builder.Services.AddIdentity<ApplicationUser, IdentityRole>(options =>
{
    options.Password.RequiredLength = 12;
    options.Password.RequireNonAlphanumeric = true;
    options.Lockout.MaxFailedAccessAttempts = 5;
    options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(15);
})
.AddEntityFrameworkStores<AppDbContext>()
.AddDefaultTokenProviders();

builder.Services.ConfigureApplicationCookie(options =>
{
    options.Cookie.HttpOnly = true;
    options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
    options.Cookie.SameSite = SameSiteMode.Strict;
    options.ExpireTimeSpan = TimeSpan.FromHours(8);
    options.SlidingExpiration = true;
});

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

Repare em três detalhes que fazem diferença real em produção. Primeiro, Password.RequiredLength = 12 e a exigência de caracteres não alfanuméricos elevam a política padrão, que é fraca demais para muitos contextos corporativos. Segundo, Lockout limita tentativas de força bruta diretamente na camada de identidade, sem precisar reinventar essa lógica. Terceiro, e mais importante do ponto de vista de segurança, a configuração do cookie: HttpOnly impede que JavaScript no navegador leia o cookie (mitigando roubo via XSS), SecurePolicy.Always garante que o cookie só trafegue por HTTPS, e SameSite = Strict reduz a superfície de ataques CSRF. Esses três atributos juntos são o que transforma um cookie de sessão simples em algo defensável contra os vetores de ataque mais comuns.

Um erro frequente é esquecer o SlidingExpiration. Sem ele, o usuário é deslogado exatamente no horário de expiração configurado, mesmo que esteja ativamente usando o sistema, o que gera reclamações de "fui deslogado do nada". Com sliding expiration habilitado, o cookie é renovado automaticamente enquanto há atividade, mantendo a experiência fluida sem comprometer a segurança de sessões abandonadas.

JWT: autenticação stateless para APIs

Quando o consumidor da autenticação não é um navegador renderizando páginas, mas sim uma API consumida por um SPA, aplicativo mobile, ou outro serviço backend, JSON Web Tokens costumam ser a escolha natural. Um JWT é essencialmente um payload assinado digitalmente, contendo claims (informações sobre o usuário) que podem ser verificadas sem consultar um banco de dados a cada requisição, já que a assinatura garante que o conteúdo não foi alterado.

Configurar autenticação JWT Bearer em ASP.NET Core exige atenção especial aos parâmetros de validação:

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = builder.Configuration["Jwt:Issuer"],
            ValidateAudience = true,
            ValidAudience = builder.Configuration["Jwt:Audience"],
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromSeconds(30),
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(builder.Configuration["Jwt:Key"]!))
        };
    });

Cada uma dessas flags de validação existe para fechar uma brecha específica. ValidateIssuer e ValidateAudience garantem que um token emitido para outra aplicação (ou por outro serviço) não seja aceito indevidamente, o que é essencial em arquiteturas com múltiplos serviços compartilhando o mesmo provedor de identidade. ValidateLifetime impede o uso de tokens expirados, e o ClockSkew reduzido de 30 segundos (o padrão da biblioteca é 5 minutos) diminui a janela de tolerância para diferenças de relógio entre servidores, o que é importante quando o token tem vida curta e você não quer que ele continue "válido" por minutos extras após expirar oficialmente.

A geração do token, no lado do servidor de autenticação, normalmente se parece com:

public string GerarToken(ApplicationUser usuario, IList<string> roles)
{
    var claims = new List<Claim>
    {
        new(JwtRegisteredClaimNames.Sub, usuario.Id),
        new(JwtRegisteredClaimNames.Email, usuario.Email!),
        new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
    };

    claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));

    var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_config["Jwt:Key"]!));
    var credenciais = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

    var token = new JwtSecurityToken(
        issuer: _config["Jwt:Issuer"],
        audience: _config["Jwt:Audience"],
        claims: claims,
        expires: DateTime.UtcNow.AddMinutes(15),
        signingCredentials: credenciais);

    return new JwtSecurityTokenHandler().WriteToken(token);
}

O ponto que mais chama atenção aqui é o tempo de expiração: apenas 15 minutos. Essa é uma decisão intencional, não um esquecimento. Tokens de acesso de vida curta limitam drasticamente o dano caso um token seja roubado, já que ele se torna inútil rapidamente. O claim Jti (JWT ID), um identificador único por token, é importante caso você precise implementar uma lista de revogação: sem ele, seria impossível invalidar um token específico antes de sua expiração natural.

Refresh tokens: resolvendo o dilema entre segurança e experiência do usuário

Se o token de acesso dura apenas 15 minutos, obrigar o usuário a fazer login novamente a cada quinze minutos seria inaceitável. É aqui que entram os refresh tokens: um token de vida longa, armazenado com mais cuidado (idealmente em um cookie HttpOnly, nunca em localStorage), usado exclusivamente para solicitar um novo token de acesso quando o atual expira.

[HttpPost("refresh")]
public async Task<IActionResult> RefreshToken([FromBody] RefreshRequest request)
{
    var tokenSalvo = await _refreshTokenStore.ObterAsync(request.RefreshToken);

    if (tokenSalvo is null || tokenSalvo.Expirado || tokenSalvo.Revogado)
        return Unauthorized();

    var usuario = await _userManager.FindByIdAsync(tokenSalvo.UsuarioId);
    if (usuario is null)
        return Unauthorized();

    await _refreshTokenStore.RevogarAsync(tokenSalvo);

    var novoAccessToken = _tokenService.GerarToken(usuario, await _userManager.GetRolesAsync(usuario));
    var novoRefreshToken = await _refreshTokenStore.CriarAsync(usuario.Id);

    return Ok(new { accessToken = novoAccessToken, refreshToken = novoRefreshToken });
}

Um detalhe crucial dessa implementação é a revogação do refresh token antigo antes de emitir um novo (rotação de token). Isso significa que cada refresh token só pode ser usado uma vez. Se um atacante conseguir roubar um refresh token e usá-lo, e depois o usuário legítimo tentar usar o mesmo token (que já foi rotacionado), você tem um sinal claro de comprometimento e pode revogar toda a cadeia de tokens daquele usuário imediatamente. Sem essa rotação, um refresh token roubado permaneceria válido silenciosamente até sua expiração natural, que geralmente é medida em dias ou semanas.

OAuth2 e OpenID Connect: delegando a autenticação

Nem sempre faz sentido implementar seu próprio sistema de login. Quando você quer permitir "Entrar com Google" ou integrar com um provedor de identidade corporativo (Azure AD, Auth0, Keycloak), você está trabalhando com OAuth2 para autorização e OpenID Connect (OIDC) como camada de autenticação construída sobre ele.

A diferença conceitual importa: OAuth2 puro foi desenhado para autorização delegada, ou seja, permitir que uma aplicação acesse recursos em nome do usuário sem conhecer sua senha. OIDC adiciona a camada que efetivamente confirma a identidade do usuário através de um id_token, um JWT específico para essa finalidade. Configurar isso em ASP.NET Core é relativamente direto:

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = "OpenIdConnect";
})
.AddCookie()
.AddOpenIdConnect("OpenIdConnect", options =>
{
    options.Authority = builder.Configuration["Oidc:Authority"];
    options.ClientId = builder.Configuration["Oidc:ClientId"];
    options.ClientSecret = builder.Configuration["Oidc:ClientSecret"];
    options.ResponseType = "code";
    options.UsePkce = true;
    options.SaveTokens = true;
    options.Scope.Add("profile");
    options.Scope.Add("email");
});

Dois pontos merecem destaque. O ResponseType = "code" combinado com UsePkce = true implementa o Authorization Code Flow com PKCE, que é hoje a recomendação de segurança padrão da indústria, inclusive para aplicações que antes usavam o Implicit Flow (esse último foi oficialmente desaconselhado justamente por expor tokens diretamente na URL do navegador). PKCE adiciona uma camada extra que impede interceptação do código de autorização mesmo em cenários onde o client secret não pode ser mantido totalmente seguro, como aplicações públicas.

Onde armazenar o token no frontend: o erro mais comum

Se existe um único ponto onde times erram sistematicamente, é aqui: guardar o token JWT em localStorage ou sessionStorage no navegador. Ambos são acessíveis via JavaScript, o que significa que qualquer vulnerabilidade de XSS na aplicação, mesmo uma pequena, se transforma automaticamente em roubo total de sessão, já que um script malicioso injetado pode simplesmente ler o token e enviá-lo para um servidor externo.

A alternativa mais segura é armazenar o token de acesso e o refresh token em cookies HttpOnly, Secure e com SameSite configurado adequadamente. Isso impede leitura via JavaScript, mitigando XSS, e o navegador se encarrega de enviar o cookie automaticamente nas requisições ao domínio correto. O trade-off é que você precisa lidar com proteção contra CSRF (já que cookies são enviados automaticamente), geralmente através de um token anti-forgery adicional ou validando o cabeçalho Origin/Referer em requisições que alteram estado.

Guia de decisão: qual abordagem usar

Para aplicações web server-side renderizadas, onde o próprio servidor gera o HTML consumido pelo navegador, autenticação por cookie com ASP.NET Core Identity é a escolha mais simples e madura, com menos peças móveis para gerenciar.

Para APIs consumidas por SPAs, aplicativos mobile, ou integrações entre serviços, JWT combinado com refresh tokens costuma ser a escolha padrão, principalmente quando a API precisa escalar horizontalmente sem depender de armazenamento de sessão compartilhado.

Quando você não quer (ou não deveria) gerenciar credenciais de usuário diretamente, seja por conformidade, seja porque o usuário já tem conta em outro provedor, OAuth2/OIDC delegando para Google, Microsoft, Auth0 ou um provedor corporativo interno reduz drasticamente a superfície de responsabilidade sobre dados sensíveis como senhas.

Em arquiteturas de microsserviços internos, onde serviços conversam entre si sem interação humana direta, vale considerar client credentials flow do OAuth2, onde cada serviço tem sua própria identidade e escopo de acesso, em vez de tentar reaproveitar tokens de usuário final para chamadas internas.

Armadilhas comuns que valem a pena revisar

Um erro recorrente é usar a mesma chave de assinatura JWT em todos os ambientes (desenvolvimento, homologação, produção), o que significa que um token gerado em um ambiente de teste, se vazado, pode ser aceito em produção. Cada ambiente deveria ter sua própria chave, gerenciada por um cofre de segredos como Azure Key Vault ou AWS Secrets Manager, nunca hardcoded no appsettings.json versionado no repositório.

Outro ponto frequentemente negligenciado é a expiração excessivamente longa de tokens de acesso "por conveniência", eliminando o incômodo de implementar refresh tokens corretamente. Isso parece resolver um problema de curto prazo, mas amplia enormemente a janela de exposição em caso de vazamento.

Por fim, muitos times esquecem de revisar o algoritmo de assinatura aceito na validação do token. Historicamente, algumas bibliotecas JWT tiveram vulnerabilidades relacionadas à aceitação do algoritmo none (sem assinatura) ou à confusão entre chaves simétricas e assimétricas. Sempre especifique explicitamente os algoritmos aceitos na validação, em vez de confiar no que vem declarado no cabeçalho do próprio token.

Conclusão

Autenticação não é um checkbox que se marca uma vez e se esquece. É uma decisão arquitetural que precisa considerar o tipo de cliente que vai consumir a aplicação, os requisitos de escalabilidade, o modelo de ameaça e a experiência esperada do usuário. Cookies com ASP.NET Core Identity continuam sendo excelentes para aplicações web tradicionais, JWT com refresh tokens é o padrão de fato para APIs modernas consumidas por SPAs e apps mobile, e OAuth2/OIDC resolve elegantemente o problema de delegar identidade para provedores externos confiáveis.

O fio condutor de tudo o que vimos aqui é que segurança robusta quase sempre vem de configurações explícitas e conservadoras: cookies marcados como HttpOnly e Secure, tokens de vida curta combinados com rotação de refresh tokens, validação estrita de issuer, audience e algoritmo de assinatura. Nenhuma dessas práticas é complexa isoladamente, mas a soma delas é o que separa uma implementação de autenticação frágil de uma que realmente resiste aos ataques mais comuns encontrados em produção.

← voltar ao índice