Destino Dev

Blog

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