Autenticação JWT em Minimal APIs: refresh tokens seguros com HttpOnly
Por John Pires · 23 de março de 2026

O que é e por que importa
Autenticação baseada em JWT (JSON Web Tokens) virou padrão em APIs modernas: tokens compactos, auto-contidos e fáceis de transportar entre cliente e servidor. Em Minimal APIs do ASP.NET Core, a configuração fica ainda mais enxuta — mas isso também abre espaço para erros de segurança se detalhes não forem considerados.
Um problema recorrente é como manter a experiência do usuário sem expor credenciais: tokens de acesso (access tokens) têm curta validade; refresh tokens permitem renovar sessões sem pedir login novamente. Porém, refresh tokens são sensíveis: se roubados, permitem manter acesso indefinidamente. Por isso, guardá-los com segurança é crítico — e é aqui que cookies HttpOnly e SameSite entram.
Este artigo investiga o problema, explora alternativas e apresenta uma solução prática e segura usando Minimal APIs, cookies HttpOnly e boas práticas de implementação no servidor.
Não se trata de um tutorial apenas de copy-paste: explico por que cada peça existe, mostro armadilhas comuns e dou um exemplo funcional passo a passo para quem está começando.
Conceitos fundamentais com analogias
JWT, access token e refresh token — a analogia do crachá e da chave
Pense em um sistema de prédio: o access token é o crachá com acesso temporário — vale por pouco tempo e contém permissões (roles). Já o refresh token é uma chave mestra que permite emitir novos crachás sem precisar voltar à recepção para autenticar novamente.
Se alguém extrai o crachá (access token), pode entrar até o crachá expirar. Se alguém pega a chave mestra (refresh token), pode gerar novos crachás e manter acesso por muito mais tempo. Portanto, a chave mestre precisa ficar mais protegida.
Cookies HttpOnly e SameSite — o cofre do caixa eletrônico
Guardar refresh tokens em localStorage é como deixar a chave mestra sobre a mesa: scripts no navegador (XSS) conseguem pegar. Cookies marcados como HttpOnly são inacessíveis a JavaScript; são como guardar a chave em um cofre que só o servidor pode acessar. Além disso, a diretiva SameSite reduz o risco de roubos por CSRF.
Combinar cookies HttpOnly com refresh tokens e acesso por bearer token (header Authorization) para o access token reduz significativamente exposição a ataques comuns.
Tip: HttpOnly não resolve XSS completamente, mas reduz vetores: combine com Content Security Policy (CSP) e validação de entrada.
Passo a passo com código comentado
Abaixo vamos construir a fundação: um minimal API que emite um access token JWT curto e um refresh token armazenado em cookie HttpOnly. O servidor também armazena o refresh token (hash) em banco para poder invalidá-lo.
1) Dependências e configuração mínima
Instale os pacotes NuGet: Microsoft.AspNetCore.Authentication.JwtBearer e Microsoft.EntityFrameworkCore (provider de sua escolha). No Minimal API (Program.cs), registramos autenticação, autorização e um serviço de tokens.
O código a seguir configura autenticação JWT e políticas básicas. Explicação: definimos parâmetro de validação do token, chave simétrica e esquema JwtBearer. Também registramos CORS e cookie policy.
using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;
var builder = WebApplication.CreateBuilder(args);
var key = Encoding.UTF8.GetBytes("sua-chave-secreta-muito-longa-aqui");
builder.Services.AddAuthentication(options =>
{
options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(key),
ClockSkew = TimeSpan.Zero
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapGet("/ping", () => "pong");
app.Run();
Contexto: esse bloco cria a infraestrutura mínima para aceitar access tokens JWT. Depois de executado, o endpoint /ping poderá ser protegido adicionando .RequireAuthorization().
O que acontece: o middleware JwtBearer lê o header Authorization: Bearer <token> e valida assinatura e validade do token.
2) Serviço de tokens: gerar access e refresh
Precisamos de um TokenService que gere o access token (curta duração) e refresh token (string longa e aleatória). O refresh token será salvo no banco (hash) com usuário e expiração.
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
public class TokenService
{
private readonly byte[] _key;
public TokenService(string secret) => _key = Encoding.UTF8.GetBytes(secret);
public string GenerateAccessToken(string userId)
{
var tokenHandler = new JwtSecurityTokenHandler();
var tokenDescriptor = new SecurityTokenDescriptor
{
Subject = new ClaimsIdentity(new[] { new Claim(ClaimTypes.NameIdentifier, userId) }),
Expires = DateTime.UtcNow.AddMinutes(5),
SigningCredentials = new SigningCredentials(new SymmetricSecurityKey(_key), SecurityAlgorithms.HmacSha256Signature)
};
var token = tokenHandler.CreateToken(tokenDescriptor);
return tokenHandler.WriteToken(token);
}
public string GenerateRefreshToken()
{
var randomNumber = new byte[64];
using var rng = RandomNumberGenerator.Create();
rng.GetBytes(randomNumber);
return Convert.ToBase64String(randomNumber);
}
}
Contexto: GenerateAccessToken cria o JWT com claim NameIdentifier. GenerateRefreshToken cria uma string aleatória baseada em RNG cripto. Não armazene o refresh token em texto puro no banco; armazene um hash.
O que acontece: o access token vence rápido (5 minutos no exemplo). O refresh token será enviado ao cliente em um cookie HttpOnly.
Exemplo prático completo
Agora vamos juntar tudo em um exemplo funcional: Minimal API com endpoints para login, refresh e logout. Usaremos um repositório simples em memória para armazenar refresh tokens com hash (apenas para aprendizado).
Estrutura de dados e utilitários
Implementaremos modelos de refresh token e funções para hash (usando SHA256). Em produção, troque o repositório por Entity Framework e adicione migrations.
using System.Security.Cryptography;
using System.Text;
public record StoredRefreshToken(string UserId, string TokenHash, DateTime Expires);
public static class RefreshTokenUtils
{
public static string HashToken(string token)
{
using var sha = SHA256.Create();
var bytes = Encoding.UTF8.GetBytes(token);
var hash = sha.ComputeHash(bytes);
return Convert.ToBase64String(hash);
}
}
// Repositório simples em memória
public class InMemoryRefreshTokenStore
{
private readonly List<StoredRefreshToken> _store = new();
public void Save(StoredRefreshToken token)
{
_store.RemoveAll(t => t.UserId == token.UserId); // opcional: permitir apenas um refresh token por user
_store.Add(token);
}
public StoredRefreshToken? GetByHash(string hash) => _store.FirstOrDefault(t => t.TokenHash == hash);
public void RemoveByUser(string userId) => _store.RemoveAll(t => t.UserId == userId);
}
Contexto: esta camada simula um banco. O hash evita salvar o refresh token em texto plano. O método RemoveByUser é útil no logout.
O que acontece: quando o usuário fizer login, vamos gerar um refresh token, salvar o hash no store e enviar o token como cookie HttpOnly.
Endpoints principais: login, refresh e logout
Segue código no Program.cs (ou arquivo principal da Minimal API). Comentários explicam o propósito de cada bloco.
var builder = WebApplication.CreateBuilder(args);
var key = "sua-chave-secreta-muito-longa-aqui";
var tokenService = new TokenService(key);
var store = new InMemoryRefreshTokenStore();
builder.Services.AddSingleton(tokenService);
builder.Services.AddSingleton(store);
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = false,
ValidateAudience = false,
ValidateIssuerSigningKey = true,
IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(key)),
ClockSkew = TimeSpan.Zero
};
});
builder.Services.AddAuthorization();
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
// Login: autentica e emite access + refresh cookie HttpOnly
app.MapPost("/login", (HttpContext http) =>
{
// Em cenário real, valide username/password. Aqui assumimos userId = "user1"
var userId = "user1";
var accessToken = tokenService.GenerateAccessToken(userId);
var refreshToken = tokenService.GenerateRefreshToken();
var hash = RefreshTokenUtils.HashToken(refreshToken);
store.Save(new StoredRefreshToken(userId, hash, DateTime.UtcNow.AddDays(7)));
http.Response.Cookies.Append("refresh_token", refreshToken, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Expires = DateTimeOffset.UtcNow.AddDays(7)
});
return Results.Ok(new { access_token = accessToken });
});
// Refresh: lê cookie HttpOnly, valida contra store e emite novo access + refresh
app.MapPost("/refresh", (HttpContext http) =>
{
if (!http.Request.Cookies.TryGetValue("refresh_token", out var refreshToken))
return Results.Unauthorized();
var hash = RefreshTokenUtils.HashToken(refreshToken);
var stored = store.GetByHash(hash);
if (stored == null || stored.Expires < DateTime.UtcNow)
{
return Results.Unauthorized();
}
// opcional: gerar novo refresh token (rotate)
var newRefresh = tokenService.GenerateRefreshToken();
var newHash = RefreshTokenUtils.HashToken(newRefresh);
store.Save(new StoredRefreshToken(stored.UserId, newHash, DateTime.UtcNow.AddDays(7)));
var newAccess = tokenService.GenerateAccessToken(stored.UserId);
http.Response.Cookies.Append("refresh_token", newRefresh, new CookieOptions
{
HttpOnly = true,
Secure = true,
SameSite = SameSiteMode.Strict,
Expires = DateTimeOffset.UtcNow.AddDays(7)
});
return Results.Ok(new { access_token = newAccess });
});
// Logout: remove cookie e remove refresh token do store
app.MapPost("/logout", (HttpContext http) =>
{
if (http.Request.Cookies.TryGetValue("refresh_token", out var refreshToken))
{
var hash = RefreshTokenUtils.HashToken(refreshToken);
var stored = store.GetByHash(hash);
if (stored != null)
store.RemoveByUser(stored.UserId);
}
http.Response.Cookies.Delete("refresh_token");
return Results.Ok();
});
app.Run();
Contexto: o endpoint /login retorna o access token no corpo (para uso em Authorization header) e escreve o refresh token no cookie HttpOnly. O endpoint /refresh é chamado pelo cliente quando o access expira; ele não precisa expor o cookie para JS — o navegador envia automaticamente o cookie HttpOnly em requests para o mesmo domínio.
O que acontece: com essa abordagem, mesmo que um atacante execute um XSS e leia localStorage, não encontrará o refresh token. Ainda assim, o access token pode ser interceptado se o cliente armazená-lo de forma insegura — prefira manter o access token em memória.
Erros comuns, armadilhas e dicas práticas
Armadilha 1: guardar refresh token em localStorage
Guardar refresh token em localStorage facilita sua leitura por scripts injetados (XSS). Mesmo com SameSite estrito e HttpOnly, ataques complexos podem explorar outra falha para roubar cookies; portanto, mitigue XSS com validações e CSP.
Armadilha 2: não invalidar refresh tokens
Não manter um repositório de refresh tokens significa que não há como invalidar sessões remotamente. Sempre persista a identidade do refresh token (hash) e ofereça logout/rotas de revogação.
Dica prática: implemente rotação de refresh tokens (issue de um novo refresh a cada refresh) e mantenha histórico curto. Se um refresh token antigo for reutilizado, considere revogar todos os tokens do usuário (indicando roubo).
Tip: ClockSkew = TimeSpan.Zero reduz janelas de tolerância; ajuste segundo sua infraestrutura de tempo.
Próximos passos para aprofundar
Depois de dominar essa base, avance para integrar com Entity Framework Core: salve refresh tokens em table com índices, user agent e IP, e implemente expiração e limpeza periódica.
Considere também:
- Adicionar refresh token rotation detection (se um token rotacionado reaparecer é sinal de comprometimento).
- Armazenar fingerprints do cliente (hash do user-agent + IP) para dificultar uso do refresh token em outro dispositivo.
- Usar refresh token com comportamento de sliding expiration ou exigir re-login após certo número de rotacionamentos.
Integre testes: escreva testes de integração que simulam login, refresh e logout; inclua casos de tokens expirados e reutilização de refresh tokens.
Conclusão prática
Resumo direto: use access tokens curtos e refresh tokens armazenados em cookies HttpOnly; guarde apenas o hash do refresh no servidor; implemente rotação e revogação. Em Minimal APIs, a implementação é direta e enxuta, mas exige disciplina nas práticas de segurança.
Próximo passo imediato para quem está começando: implemente o exemplo acima localmente, troque o InMemoryStore por uma tabela do EF Core e escreva um teste que simule refresh e revogação.
Seguindo essas práticas, você reduz exposição a XSS/CSRF e ganha controle sobre sessões de usuários — fundamentais em qualquer aplicação web que manipule dados sensíveis.
Assuntos: C#, jwt, minimal-apis, refresh-token, httponly
Leia também
Consultas dinâmicas seguras com Expression Trees e Specification no EF Core
Como construir consultas dinâmicas seguras no EF Core usando Expression Trees e Specification: padrões, código pronto e soluções para problemas de tradução e…
EF Core: Upsert, ExecuteUpdate/ExecuteDelete e estratégias de operações em lote
Guia prático para aplicar upserts e operações em lote com EF Core: quando usar ExecuteUpdate/ExecuteDelete, MERGE/ON CONFLICT, bibliotecas de bulk e patterns…
EF Core + Dapper: padrão híbrido (Read/Write) para consultas de alta performance
Padrão híbrido: use EF Core para writes e Dapper para reads. Mostro código real — repositórios, transações compartilhadas, queries parametrizadas e paginação…
JWT em Minimal APIs (ASP.NET Core): access e refresh tokens do zero
Aprenda do zero como implementar access e refresh tokens com JWT em Minimal APIs do ASP.NET Core: código real, armazenamento de refresh token, endpoints…
Minimal APIs + SSE: enviar progresso de jobs para clientes Blazor passo a passo
Guia passo a passo para usar Minimal APIs + SSE e enviar progresso de jobs para clientes Blazor. Do setup ao código funcional: servidor com endpoint SSE…
Upload de arquivos com ASP.NET Core Minimal APIs: streaming e progresso
Guia prático para implementar upload de arquivos com ASP.NET Core Minimal APIs: streaming para evitar OOM, reporting de progresso via polling e dicas para…