No artigo sobre o JWT Decoder você viu o que existe dentro de um token: header, payload e assinatura. Agora vamos para o outro lado do balcão — emitir e validar esses tokens numa API ASP.NET Core, protegendo endpoints e implementando o fluxo completo de autenticação: login, access token, endpoints protegidos e refresh token com rotação.

Autenticação é uma daquelas coisas que parecem simples até você reparar nos detalhes que quebram a segurança: uma chave fraca, a expiração que nunca chega, o refresh token que nunca é invalidado. Este guia é um passo a passo prático para .NET 10, usando as APIs atuais (nada de código legado) e apontando as armadilhas no caminho.

Como o JWT funciona (versão rápida)

Um JWT tem três partes separadas por ponto: header.payload.signature. Header e payload são apenas Base64Url — não são criptografados, qualquer um lê. Quem garante que o token não foi adulterado é a assinatura, calculada com uma chave secreta que só o servidor conhece.

A consequência prática, que vale gravar: a assinatura protege a integridade, não a confidencialidade. Nunca coloque senha ou dado sensível no payload — colar o token em qualquer decoder revela tudo.

O fluxo que vamos montar usa dois tokens com papéis diferentes:

  • Access token — um JWT de vida curta (5 a 15 minutos), enviado a cada requisição no header Authorization: Bearer. É stateless: a API valida a assinatura e a expiração sem consultar banco.
  • Refresh token — uma string opaca de vida longa (dias), guardada no servidor. Serve para obter um novo access token quando o antigo expira, sem pedir login de novo. Como vive no servidor, pode ser revogado.

Pré-requisitos

  • SDK do .NET 10 instalado (dotnet --version10.0.x).
  • Uma API Web. Se estiver começando do zero: dotnet new webapi -n AuthDemo.

Passo 1 — Instalar os pacotes

# Valida os tokens no middleware
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

# Cria e assina os tokens (handler moderno)
dotnet add package Microsoft.IdentityModel.JsonWebTokens

Versões: use a família 10.x do JwtBearer (alinhada ao runtime) e a 8.x do Microsoft.IdentityModel.*. O JwtBearer já traz Microsoft.IdentityModel.Tokens como dependência, então os tipos base (TokenValidationParameters, SymmetricSecurityKey) vêm junto.

⚠️ Não use System.IdentityModel.Tokens.Jwt (JwtSecurityTokenHandler) em código novo — é considerado legado. Desde o .NET 8 o padrão é o JsonWebTokenHandler, ~30% mais rápido, assíncrono e compatível com AOT.

🐛 Se a autenticação falhar com MethodNotFound em Base64UrlEncoder.Decode: é conflito de versões do IdentityModel — o NuGet resolveu um Microsoft.IdentityModel.* antigo ao lado do JwtBearer 10.x. A correção é alinhar a família, fixando explicitamente Microsoft.IdentityModel.JsonWebTokens (e, se necessário, Microsoft.IdentityModel.Protocols.OpenIdConnect) na versão 8 atual.

Passo 2 — Configurar a chave e o appsettings

Adicione uma seção Jwt ao appsettings.json. A chave do HS256 precisa ter no mínimo 32 bytes (256 bits) — uma chave menor lança exceção na hora de assinar.

{
  "Jwt": {
    "Issuer": "https://minha-api.com",
    "Audience": "https://meu-app.com",
    "Key": "troque-esta-chave-por-uma-secreta-com-32-bytes-ou-mais"
  }
}

Gere uma chave forte e aleatória (não invente à mão) com:

openssl rand -base64 32

🔒 Nunca faça commit da chave real. Em desenvolvimento use dotnet user-secrets set "Jwt:Key" "..."; em produção, variáveis de ambiente ou um cofre (Google Secret Manager, Azure Key Vault). A chave em appsettings.json acima é só para o tutorial rodar.

Passo 3 — Configurar a autenticação no Program.cs

Aqui a API aprende a validar os tokens que chegam:

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.Security.Claims;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

var jwt = builder.Configuration.GetSection("Jwt");
var signingKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(jwt["Key"]!));

builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddJwtBearer(options =>
    {
        options.MapInboundClaims = false;   // veja a nota abaixo — importante!
        options.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = jwt["Issuer"],
            ValidateAudience = true,
            ValidAudience = jwt["Audience"],
            ValidateLifetime = true,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = signingKey,
            ClockSkew = TimeSpan.Zero,       // expiração estrita
            NameClaimType = JwtRegisteredClaimNames.Sub,  // User.Identity.Name = sub
            RoleClaimType = "role"           // [Authorize(Roles=...)] lê o claim "role"
        };
    });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();   // SEMPRE antes de UseAuthorization
app.UseAuthorization();

app.Run();

Dois detalhes que quase todo tutorial erra:

ClockSkew = TimeSpan.Zero — por padrão o ClockSkew é de 5 minutos, uma tolerância para relógios dessincronizados entre servidores. Na prática, isso faz um token “expirado” continuar válido por até 5 min. Se seus servidores têm relógio sincronizado (NTP) e os access tokens são curtos, zere o skew para expiração exata.

MapInboundClaims = false — este é o gotcha clássico. Por padrão, o JwtBearer remapeia o claim sub para uma URI gigante (http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier). Com MapInboundClaims = false, os claims chegam com o nome original (sub, email, role), como você espera. Sem isso, User.FindFirst("sub") volta null e você perde tempo achando que é bug.

RoleClaimType e NameClaimType — consequência direta do ponto acima, e a pegadinha mais traiçoeira. Ao desligar o mapeamento e emitir o papel no claim role, você precisa dizer ao ASP.NET Core onde ler o papel. Sem RoleClaimType = "role", o [Authorize(Roles = "Admin")] procura o papel na URI padrão do .NET, não encontra e devolve 403 mesmo com um token de Admin válido. Por isso as duas linhas são obrigatórias aqui.

Ordem importa: UseAuthentication() sempre vem antes de UseAuthorization(). Autenticar é “quem é você”; autorizar é “você pode”.

Passo 4 — Gerar o access token

O JsonWebTokenHandler.CreateToken recebe um SecurityTokenDescriptor e devolve a string do JWT direto (o handler legado exigia um passo a mais com WriteToken).

using Microsoft.IdentityModel.JsonWebTokens;
using Microsoft.IdentityModel.Tokens;
using System.Text;

public sealed class TokenService(IConfiguration config)
{
    // Criados uma vez: o handler é thread-safe e cacheia o provedor de assinatura
    // por instância de chave — então a SigningCredentials também tem de ser reutilizada.
    private static readonly JsonWebTokenHandler Handler = new();
    private readonly SigningCredentials _credentials = new(
        new SymmetricSecurityKey(Encoding.UTF8.GetBytes(config["Jwt:Key"]!)),
        SecurityAlgorithms.HmacSha256);   // gera "alg": "HS256"
    private readonly string? _issuer = config["Jwt:Issuer"];
    private readonly string? _audience = config["Jwt:Audience"];

    public string CreateAccessToken(Guid userId, string email, string role)
    {
        var now = DateTime.UtcNow;
        var descriptor = new SecurityTokenDescriptor
        {
            Issuer = _issuer,
            Audience = _audience,
            IssuedAt = now,
            NotBefore = now,
            Expires = now.AddMinutes(10),     // access token curto
            SigningCredentials = _credentials,
            Claims = new Dictionary<string, object>
            {
                [JwtRegisteredClaimNames.Sub] = userId.ToString(),
                [JwtRegisteredClaimNames.Email] = email,
                ["role"] = role,
                [JwtRegisteredClaimNames.Jti] = Guid.NewGuid().ToString()
            }
        };

        return Handler.CreateToken(descriptor);
    }
}

TokenService é registrado como singleton (AddSingleton), então a chave e as credenciais são montadas uma única vez — não a cada requisição.

Registre o serviço no Program.cs: builder.Services.AddSingleton<TokenService>();

Passo 5 — O refresh token (com rotação)

Aqui está a parte que separa um tutorial de brinquedo de uma implementação segura. O refresh token não é um JWT — é uma string aleatória opaca, guardada no servidor com hash. Regras de ouro (alinhadas ao RFC 9700 e ao OWASP):

  • Aleatório e opaco: gerado com um RNG criptográfico.
  • Armazenado com hash (nunca em texto puro), associado ao usuário, com expiração.
  • Rotação: a cada refresh, o token antigo é invalidado e um novo é emitido.
  • Detecção de reúso: se um token já rotacionado for usado de novo, é sinal de roubo → revogue toda a família de tokens e force novo login.

Gerar um token seguro e o hash de armazenamento:

using System.Security.Cryptography;
using System.Text;
using Microsoft.AspNetCore.Authentication;

public static class RefreshTokens
{
    // 256 bits de aleatoriedade, em base64url
    public static string Generate() =>
        Base64UrlTextEncoder.Encode(RandomNumberGenerator.GetBytes(32));

    // guardamos apenas o hash no banco
    public static string Hash(string token) =>
        Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(token)));
}

A entidade persistida (via EF Core, por exemplo) guarda o essencial para rotação e revogação:

public sealed class RefreshToken
{
    public Guid Id { get; set; }
    public Guid UserId { get; set; }
    public string TokenHash { get; set; } = "";   // nunca o token em si
    public Guid FamilyId { get; set; }             // agrupa a cadeia de rotações
    public DateTime ExpiresUtc { get; set; }
    public bool IsUsed { get; set; }               // já rotacionado?
    public bool IsRevoked { get; set; }
}

Em produção, crie um índice na coluna TokenHash — cada validação de refresh é uma busca por esse hash.

Passo 6 — Endpoints de login e refresh

Com as peças no lugar, os dois endpoints ficam diretos. (A validação de credenciais — IUserService — fica abstraída; o essencial é que a senha seja verificada com um hash forte, como o PasswordHasher do ASP.NET Core Identity, nunca em texto puro.)

app.MapPost("/auth/login", async (LoginDto dto, IUserService users,
    TokenService tokens, IRefreshTokenStore store) =>
{
    var user = await users.ValidateCredentialsAsync(dto.Email, dto.Password);
    if (user is null) return Results.Unauthorized();

    var access = tokens.CreateAccessToken(user.Id, user.Email, user.Role);
    var refresh = RefreshTokens.Generate();

    await store.AddAsync(new RefreshToken
    {
        Id = Guid.NewGuid(),
        UserId = user.Id,
        TokenHash = RefreshTokens.Hash(refresh),
        FamilyId = Guid.NewGuid(),                 // nova família a cada login
        ExpiresUtc = DateTime.UtcNow.AddDays(14)
    });

    return Results.Ok(new { accessToken = access, refreshToken = refresh });
});

app.MapPost("/auth/refresh", async (RefreshDto dto, IUserService users,
    TokenService tokens, IRefreshTokenStore store) =>
{
    var stored = await store.FindByHashAsync(RefreshTokens.Hash(dto.RefreshToken));
    if (stored is null) return Results.Unauthorized();

    // Detecção de reúso ANTES da expiração: um token roubado e já expirado
    // ainda dispara a revogação da família (não perde o sinal de theft detection).
    if (stored.IsUsed || stored.IsRevoked)
    {
        await store.RevokeFamilyAsync(stored.FamilyId);   // mata a cadeia inteira
        return Results.Unauthorized();
    }

    if (stored.ExpiresUtc < DateTime.UtcNow) return Results.Unauthorized();

    // Busca o usuário ANTES de escrever: se foi deletado, nem rotaciona.
    var user = await users.GetByIdAsync(stored.UserId);
    if (user is null) return Results.Unauthorized();

    await store.MarkUsedAsync(stored.Id);          // rotação: invalida o antigo
    var newRefresh = RefreshTokens.Generate();
    await store.AddAsync(new RefreshToken
    {
        Id = Guid.NewGuid(),
        UserId = stored.UserId,
        TokenHash = RefreshTokens.Hash(newRefresh),
        FamilyId = stored.FamilyId,                // mantém a mesma família
        ExpiresUtc = DateTime.UtcNow.AddDays(14)
    });

    var access = tokens.CreateAccessToken(user.Id, user.Email, user.Role);
    return Results.Ok(new { accessToken = access, refreshToken = newRefresh });
});

Concorrência: MarkUsedAsync + AddAsync deveriam rodar na mesma transação (ou com concorrência otimista — ex.: uma coluna rowversion no RefreshToken). Sem isso, dois refreshes simultâneos com o mesmo token podem gerar duas cadeias válidas. Em produção, envolva a rotação numa transação do EF Core.

Body vs. cookie: para o tutorial ficar legível, os endpoints devolvem os tokens no corpo JSON. Em produção, o mais seguro é enviar o refresh token num cookie HttpOnly + Secure + SameSite — fora do alcance de scripts (veja as armadilhas de segurança adiante).

Logout: revogar o refresh token

Como o access token é stateless, “deslogar” de verdade é cortar a fonte de novos access tokens — ou seja, revogar o refresh token no servidor. O endpoint de logout revoga a família inteira:

app.MapPost("/auth/logout", async (RefreshDto dto, IRefreshTokenStore store) =>
{
    var stored = await store.FindByHashAsync(RefreshTokens.Hash(dto.RefreshToken));
    if (stored is not null)
        await store.RevokeFamilyAsync(stored.FamilyId);

    return Results.NoContent();
});

Depois disso, nenhum refresh gera novo token. O access token atual continua válido até expirar (por isso ele é curto) — se precisar de revogação imediata, mantenha uma denylist de jti até o exp.

Passo 7 — Proteger endpoints e ler o usuário

Com a autenticação configurada, exigir um token válido é uma linha:

// Requer qualquer usuário autenticado
app.MapGet("/me", (ClaimsPrincipal user) =>
{
    var sub = user.FindFirstValue(JwtRegisteredClaimNames.Sub);
    var email = user.FindFirstValue(JwtRegisteredClaimNames.Email);
    return Results.Ok(new { sub, email });
}).RequireAuthorization();

// Requer o papel "Admin"
app.MapDelete("/admin/users/{id}", (Guid id) => Results.NoContent())
   .RequireAuthorization(policy => policy.RequireRole("Admin"));

Em controllers, o equivalente é o atributo [Authorize] / [Authorize(Roles = "Admin")].

Para regras mais ricas que “tem o papel X”, use políticas:

builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("GerenteFinanceiro", policy =>
        policy.RequireRole("Manager")
              .RequireClaim("department", "finance"));
});

// uso: .RequireAuthorization("GerenteFinanceiro")  ou  [Authorize(Policy = "...")]

No Program.csuma chamada AddAuthorization (a do Passo 3). Para registrar políticas, troque-a pela sobrecarga com options => acima — não chame AddAuthorization duas vezes. O trecho mostra só o miolo da configuração.

Passo 8 — Testar

Faça login, capture o token e chame um endpoint protegido:

# 1) login
curl -X POST http://localhost:5000/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"user@exemplo.com","password":"senha123"}'
# { "accessToken": "eyJ...", "refreshToken": "Rm9v..." }

# 2) acessar rota protegida com o access token
curl http://localhost:5000/me \
  -H "Authorization: Bearer eyJ..."

# 3) renovar quando o access token expirar
curl -X POST http://localhost:5000/auth/refresh \
  -H "Content-Type: application/json" \
  -d '{"refreshToken":"Rm9v..."}'

Um aviso sobre o dotnet user-jwts: essa ferramenta do SDK gera JWTs de desenvolvimento, mas com configuração própria — issuer dotnet-user-jwts, audience da URL de launch e uma chave gravada no user-secrets. Nada disso bate com o Jwt:Issuer/Jwt:Audience/Jwt:Key que fixamos no TokenValidationParameters, então o token dela toma 401 neste tutorial. Ela só funciona out-of-the-box quando o AddJwtBearer() lê a seção Authentication:Schemes:Bearer automaticamente (sem TokenValidationParameters manual). Aqui, gere os tokens pelo próprio /auth/login.

Cole o accessToken do /auth/login no JWT Decoder para ver, na prática, os claims que você acabou de emitir.

Armadilhas de segurança (leia antes de ir para produção)

  • Nada de segredo no payload. JWT é Base64, não criptografia. Se dá para ler no decoder, um atacante também lê. Só claims não sensíveis.
  • Chave de assinatura fora do código. user-secrets em dev, cofre em produção. Vazou a chave, vazou tudo.
  • Onde guardar o token no cliente: localStorage é vulnerável a XSS (qualquer script lê o token). O recomendado é cookie HttpOnly + Secure + SameSite, idealmente com o backend intermediando (padrão BFF).
  • Sempre HTTPS, sempre validando issuer e audience, sempre com expiração curta no access token.
  • Logout com JWT stateless revoga o refresh token (o /auth/logout acima), não o access token: o access atual continua válido até o exp — daí ele ser curto. Para revogação imediata, use uma denylist de jti. O refresh token, server-side, é o seu ponto de controle para deslogar e para theft detection.
  • Rate limiting em /auth/login e /auth/refresh: sem isso, viram alvo de brute force e credential stuffing. Use o rate limiter nativo do ASP.NET Core (builder.Services.AddRateLimiter(...) + .RequireRateLimiting(...)).

HS256 ou RS256?

Usamos HS256 (chave simétrica) por simplicidade: a mesma chave assina e valida. É adequado quando o mesmo serviço emite e valida os tokens — o caso deste tutorial.

Em produção com múltiplos serviços, prefira RS256/ES256 (assimétrico): a chave privada assina, a pública valida. Os serviços validadores nunca veem o segredo, e você pode publicar a chave pública num endpoint JWKS. E, sempre que possível em produção, não reinvente o login: apoie-se em OpenID Connect / OAuth 2.0, que é o padrão do mercado por bons motivos.

Conclusão

Autenticação JWT bem-feita não é sobre gerar um token — é sobre os detalhes ao redor: expiração curta, validação estrita, chave protegida e um refresh token server-side com rotação e detecção de reúso. Com o esqueleto deste guia você tem um fluxo de login seguro e moderno em .NET 10, usando as APIs atuais e evitando as armadilhas que costumam passar despercebidas.

Precisa de uma API segura, com autenticação e autorização bem arquitetadas? A DevPlus projeta e constrói back-ends .NET em produção. Fale com a gente.

Veja também: JWT Decoder: como decodificar e inspecionar um token, Clean Architecture no .NET do zero e Clean Code em C#: práticas essenciais.