From n00b to ZeroCool / Origen

Login, sesiones y tokens: quién eres y por qué te creo

Arma login seguro en .NET 8 Web API + Dapper + MySQL: hash de passwords, JWT, refresh token, rotación y checklist para deploy.

Lo que vale la pena leer aquí

Y justo cuando crees que ya la libraste, cae el mensaje: “Oye, nada más falta el login para que lo subamos a production”.

Intro con gancho

Son las 11:48 pm. Tu laptop ya pide clemencia, pero el CRUD por fin jala. El frontend en Vite levanta, Tailwind lo dejó decente y hasta te echaste un par de pantallas para que no se vea “proyecto de curso a medias”.

Y justo cuando crees que ya la libraste, cae el mensaje: “Oye, nada más falta el login para que lo subamos a production”.

Ahí empieza el verdadero problema.

Porque el login no es una pantalla. Es un acuerdo: “yo digo quién soy” y tu backend decide si te cree, por cuánto tiempo, y qué pasa cuando algo sale mal (token robado, sesión vencida, cambio urgente del jefe, deploy tarde, etc.).

Qué vas a aprender

  • La diferencia real entre sesión y token (y por qué se mezclan en pláticas y tickets).
  • Un flujo práctico con .NET 8 Web API + Dapper + MySQL:
    • registro básico (hash de password)
    • login
    • emisión de JWT access token
    • refresh token (opcional, pero neta: recomendado)
  • Cómo proteger endpoints con Authorize y claims.
  • Decisiones que sí te pegan en la vida real: expiración, almacenamiento en el frontend, rotación y revocación.

Contexto práctico (sin humo)

Un stack típico: React habla con tu API en .NET, y tu API habla con MySQL. El detalle que nadie te perdona:

  • HTTP es stateless: cada request llega como si fuera la primera.
  • Tú necesitas “memoria” para saber si quien pega a /api/orders es un usuario autenticado o alguien random.

Dos caminos comunes:

Sesiones (stateful)

El server guarda estado: “la sesión de Juan sigue viva”, usualmente con un ID de sesión. El cliente manda ese ID (cookie).

  • Pro: invalidar desde el server es directo.
  • Contra: tienes que guardar sesiones (memoria/Redis/DB) y pensar en escalado.

Tokens (stateless)

El server firma un token (JWT) con datos (claims). El cliente lo manda en cada request.

  • Pro: escala fácil, no guardas sesión por usuario.
  • Contra: revocar es más tricky; si se filtra, lo usan hasta que expire.

Por eso mucha banda acaba en híbrido:

  • JWT corto (access token)
  • refresh token guardado en DB para renovar y poder revocar

Eso armamos aquí.

Guía principal: login con JWT + refresh token en .NET 8 Web API

1) Esquema de MySQL (SQL versionado)

Guarda estos scripts en algo como sql/001_create_auth_tables.sql y aplícalos igual en local, staging y producción. Cuando un freelance cotiza “rápido y barato” y luego hay rollback, lo que salva es que tu setup sea repetible.

CREATE TABLE users (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  email VARCHAR(190) NOT NULL,
  password_hash VARCHAR(255) NOT NULL,
  name VARCHAR(120) NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  UNIQUE KEY uq_users_email (email)
);

CREATE TABLE refresh_tokens (
  id BIGINT PRIMARY KEY AUTO_INCREMENT,
  user_id BIGINT NOT NULL,
  token_hash VARCHAR(255) NOT NULL,
  expires_at DATETIME NOT NULL,
  revoked_at DATETIME NULL,
  created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  CONSTRAINT fk_refresh_user FOREIGN KEY (user_id) REFERENCES users(id)
);

CREATE INDEX ix_refresh_user_id ON refresh_tokens(user_id);
CREATE INDEX ix_refresh_token_hash ON refresh_tokens(token_hash);

Tradeoff real: guardamos el refresh token hasheado. Si un día alguien se lleva tu dump de DB (pasa), al menos no se lleva tokens listos para usarse.

2) Configuración base en appsettings

En appsettings.json:

{
  "ConnectionStrings": {
    "Default": "Server=localhost;Port=3306;Database=byteit;Uid=root;Pwd=tu_password;"
  },
  "Auth": {
    "JwtIssuer": "byteit-api",
    "JwtAudience": "byteit-web",
    "JwtKey": "CAMBIA-ESTO-POR-UNA-LLAVE-LARGA-Y-SECRETA",
    "AccessTokenMinutes": 15,
    "RefreshTokenDays": 14
  }
}

En producción, esa llave no vive en el repo. Ponla como secret/variable de entorno. Aunque sea “una app chiquita para un negocio que nomás necesita que funcione”. Justo esas son las que terminan expuestas.

3) Modelos y DTOs

DTOs simples para registro/login:

public record RegisterRequest(string Email, string Password, string? Name);
public record LoginRequest(string Email, string Password);

public record AuthResponse(
    string AccessToken,
    DateTime AccessTokenExpiresAt,
    string RefreshToken,
    DateTime RefreshTokenExpiresAt);

4) Password hashing (nada de guardar plain text)

Usa el hasher integrado de ASP.NET Core. No te la juegues con “mi hash casero” porque viste un snippet en un hilo.

using Microsoft.AspNetCore.Identity;

public class PasswordService
{
    private readonly PasswordHasher<object> _hasher = new();

    public string Hash(string password) => _hasher.HashPassword(new object(), password);

    public bool Verify(string hash, string password)
        => _hasher.VerifyHashedPassword(new object(), hash, password)
           == PasswordVerificationResult.Success;
}

Decisión práctica: esto te evita inventar criptografía en viernes por la tarde con deadline encima.

5) Repos con Dapper (Users + RefreshTokens)

Conexión a MySQL y queries explícitas. Aquí no hay ORM mágico: tú decides qué se consulta y qué se guarda.

using Dapper;
using MySqlConnector;

public class Db
{
    private readonly string _cs;
    public Db(IConfiguration cfg) => _cs = cfg.GetConnectionString("Default")!;
    public MySqlConnection Open() => new(_cs);
}

public record UserRow(long Id, string Email, string Password_Hash, string? Name);

public class UsersRepo
{
    private readonly Db _db;
    public UsersRepo(Db db) => _db = db;

    public async Task<UserRow?> FindByEmail(string email)
    {
        using var conn = _db.Open();
        return await conn.QuerySingleOrDefaultAsync<UserRow>(
            "SELECT id, email, password_hash as Password_Hash, name FROM users WHERE email = @email",
            new { email });
    }

    public async Task<long> Create(string email, string passwordHash, string? name)
    {
        using var conn = _db.Open();
        var sql = @"
INSERT INTO users(email, password_hash, name)
VALUES(@email, @passwordHash, @name);
SELECT LAST_INSERT_ID();";

        return await conn.ExecuteScalarAsync<long>(sql, new { email, passwordHash, name });
    }
}

public class RefreshTokensRepo
{
    private readonly Db _db;
    public RefreshTokensRepo(Db db) => _db = db;

    public async Task<long> Create(long userId, string tokenHash, DateTime expiresAt)
    {
        using var conn = _db.Open();
        var sql = @"
INSERT INTO refresh_tokens(user_id, token_hash, expires_at)
VALUES(@userId, @tokenHash, @expiresAt);
SELECT LAST_INSERT_ID();";

        return await conn.ExecuteScalarAsync<long>(sql, new { userId, tokenHash, expiresAt });
    }

    public async Task<(long Id, long UserId, DateTime ExpiresAt, DateTime? RevokedAt)?> FindByHash(string tokenHash)
    {
        using var conn = _db.Open();
        var sql = @"
SELECT id as Id, user_id as UserId, expires_at as ExpiresAt, revoked_at as RevokedAt
FROM refresh_tokens
WHERE token_hash = @tokenHash
ORDER BY id DESC
LIMIT 1;";

        return await conn.QuerySingleOrDefaultAsync<(long, long, DateTime, DateTime?)>(sql, new { tokenHash });
    }

    public async Task Revoke(long id)
    {
        using var conn = _db.Open();
        await conn.ExecuteAsync(
            "UPDATE refresh_tokens SET revoked_at = NOW() WHERE id = @id AND revoked_at IS NULL",
            new { id });
    }
}

6) Servicio de JWT + refresh token

Generamos:

  • access token (JWT firmado)
  • refresh token random fuerte
  • hash del refresh token para DB
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using Microsoft.IdentityModel.Tokens;

public class AuthSettings
{
    public string JwtIssuer { get; set; } = "";
    public string JwtAudience { get; set; } = "";
    public string JwtKey { get; set; } = "";
    public int AccessTokenMinutes { get; set; } = 15;
    public int RefreshTokenDays { get; set; } = 14;
}

public class TokenService
{
    private readonly AuthSettings _s;
    public TokenService(AuthSettings s) => _s = s;

    public (string Token, DateTime ExpiresAt) CreateAccessToken(long userId, string email)
    {
        var now = DateTime.UtcNow;
        var expires = now.AddMinutes(_s.AccessTokenMinutes);

        var claims = new List<Claim>
        {
            new(JwtRegisteredClaimNames.Sub, userId.ToString()),
            new(JwtRegisteredClaimNames.Email, email),
            new("uid", userId.ToString())
        };

        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_s.JwtKey));
        var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

        var jwt = new JwtSecurityToken(
            issuer: _s.JwtIssuer,
            audience: _s.JwtAudience,
            claims: claims,
            notBefore: now,
            expires: expires,
            signingCredentials: creds);

        return (new JwtSecurityTokenHandler().WriteToken(jwt), expires);
    }

    public string CreateRefreshToken()
    {
        var bytes = RandomNumberGenerator.GetBytes(64);
        return Convert.ToBase64String(bytes);
    }

    public string HashRefreshToken(string refreshToken)
    {
        using var sha = SHA256.Create();
        var bytes = sha.ComputeHash(Encoding.UTF8.GetBytes(refreshToken));
        return Convert.ToHexString(bytes);
    }
}

7) Configura Authentication/Authorization en Program.cs

using Microsoft.AspNetCore.Authentication.JwtBearer;
using Microsoft.IdentityModel.Tokens;
using System.Text;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers();

builder.Services.AddSingleton<Db>();
builder.Services.AddSingleton<UsersRepo>();
builder.Services.AddSingleton<RefreshTokensRepo>();
builder.Services.AddSingleton<PasswordService>();

var authSettings = builder.Configuration.GetSection("Auth").Get<AuthSettings>()!;
builder.Services.AddSingleton(authSettings);
builder.Services.AddSingleton<TokenService>();

builder.Services
  .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
  .AddJwtBearer(o =>
  {
      o.TokenValidationParameters = new()
      {
          ValidateIssuer = true,
          ValidateAudience = true,
          ValidateLifetime = true,
          ValidateIssuerSigningKey = true,
          ValidIssuer = authSettings.JwtIssuer,
          ValidAudience = authSettings.JwtAudience,
          IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(authSettings.JwtKey)),
          ClockSkew = TimeSpan.FromSeconds(30)
      };
  });

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseHttpsRedirection();
app.UseAuthentication();
app.UseAuthorization();
app.MapControllers();

app.Run();

Momento de guerra: si se te olvida UseAuthentication() vas a perder una hora viendo 401 “misteriosos”, echándole la culpa a tu token, al CORS o a la vida. Es el middleware.

8) Controller: register, login, refresh

using Microsoft.AspNetCore.Mvc;

[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly UsersRepo _users;
    private readonly RefreshTokensRepo _refresh;
    private readonly PasswordService _pw;
    private readonly TokenService _tokens;
    private readonly AuthSettings _s;

    public AuthController(UsersRepo users, RefreshTokensRepo refresh, PasswordService pw, TokenService tokens, AuthSettings s)
    {
        _users = users;
        _refresh = refresh;
        _pw = pw;
        _tokens = tokens;
        _s = s;
    }

    [HttpPost("register")]
    public async Task<IActionResult> Register(RegisterRequest req)
    {
        var email = req.Email.Trim().ToLowerInvariant();
        if (string.IsNullOrWhiteSpace(email) || req.Password.Length < 8)
            return BadRequest("Email válido y password mínimo de 8 caracteres.");

        var exists = await _users.FindByEmail(email);
        if (exists is not null) return Conflict("Ese email ya existe.");

        var hash = _pw.Hash(req.Password);
        var id = await _users.Create(email, hash, req.Name);

        return Created($"/api/users/{id}", new { id, email });
    }

    [HttpPost("login")]
    public async Task<ActionResult<AuthResponse>> Login(LoginRequest req)
    {
        var email = req.Email.Trim().ToLowerInvariant();
        var user = await _users.FindByEmail(email);

        // mensaje genérico para no filtrar si existe o no
        if (user is null || !_pw.Verify(user.Password_Hash, req.Password))
            return Unauthorized("Credenciales inválidas.");

        var (access, accessExp) = _tokens.CreateAccessToken(user.Id, user.Email);

        var refreshToken = _tokens.CreateRefreshToken();
        var refreshHash = _tokens.HashRefreshToken(refreshToken);
        var refreshExp = DateTime.UtcNow.AddDays(_s.RefreshTokenDays);

        await _refresh.Create(user.Id, refreshHash, refreshExp);

        return new AuthResponse(access, accessExp, refreshToken, refreshExp);
    }

    public record RefreshRequest(string RefreshToken);

    [HttpPost("refresh")]
    public async Task<ActionResult<AuthResponse>> Refresh(RefreshRequest req)
    {
        if (string.IsNullOrWhiteSpace(req.RefreshToken))
            return BadRequest("Falta refreshToken");

        var hash = _tokens.HashRefreshToken(req.RefreshToken);
        var row = await _refresh.FindByHash(hash);

        if (row is null) return Unauthorized("Refresh inválido.");
        if (row.Value.RevokedAt is not null) return Unauthorized("Refresh revocado.");
        if (row.Value.ExpiresAt <= DateTime.UtcNow) return Unauthorized("Refresh expirado.");

        // rotación: revoca el viejo y emite uno nuevo
        await _refresh.Revoke(row.Value.Id);

        // en un sistema real, aquí también validarías que el usuario siga activo
        var userId = row.Value.UserId;

        // Quick hack razonable para el post: añade un método FindById.
        return await RefreshWithUserLookup(userId);
    }

    private async Task<ActionResult<AuthResponse>> RefreshWithUserLookup(long userId)
    {
        // mini lookup con Dapper directo (para mantener corto el repo en el post)
        // En tu código real: UsersRepo.FindById
        var db = HttpContext.RequestServices.GetRequiredService<Db>();
        using var conn = db.Open();
        var user = await conn.QuerySingleOrDefaultAsync<UserRow>(
            "SELECT id, email, password_hash as Password_Hash, name FROM users WHERE id = @id",
            new { id = userId });

        if (user is null) return Unauthorized("Usuario no encontrado.");

        var (access, accessExp) = _tokens.CreateAccessToken(user.Id, user.Email);

        var refreshToken = _tokens.CreateRefreshToken();
        var refreshHash = _tokens.HashRefreshToken(refreshToken);
        var refreshExp = DateTime.UtcNow.AddDays(_s.RefreshTokenDays);

        var refreshRepo = HttpContext.RequestServices.GetRequiredService<RefreshTokensRepo>();
        await refreshRepo.Create(user.Id, refreshHash, refreshExp);

        return new AuthResponse(access, accessExp, refreshToken, refreshExp);
    }
}

Decisión práctica: rotación del refresh token. Si alguien te roba un refresh viejo y tú ya lo rotaste, ese token queda inútil. Esto aplica en la vida real: laptop perdida, malware, extensión rara del navegador, una sesión abierta en la compu del trabajo, etc.

9) Proteger un endpoint y leer el usuario

Ejemplo de endpoint protegido:

using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using System.Security.Claims;

[ApiController]
[Route("api/me")]
public class MeController : ControllerBase
{
    [Authorize]
    [HttpGet]
    public IActionResult Get()
    {
        var uid = User.FindFirstValue("uid");
        var email = User.FindFirstValue(ClaimTypes.Email) ?? User.FindFirstValue("email");

        return Ok(new { uid, email });
    }
}

Nota: el claim de email puede variar según cómo lo emitiste. Si sale null, no te paniquees: decodifica el JWT y revisa qué claim estás mandando.

Login, sesiones y tokens: quién eres y por qué te creo - visual explicativa 1
Visual de apoyo: Intro con gancho

Cómo lo consumes desde React (Vite) sin dispararte en el pie

No hace falta armar todo el frontend para tomar buenas decisiones. La bronca real es: dónde guardas el token y cómo respondes cuando el backend te manda 401 en pleno demo.

Dónde guardo el token

  • Access token: en memoria (state) o en storage. En memoria es más seguro contra XSS, pero pierdes sesión al recargar la página.
  • Refresh token: guardarlo en localStorage es práctico, pero sube el riesgo si algún día tienes XSS.

Lo más sano en apps con usuarios reales suele ser refresh token en cookie HttpOnly. Peeero eso implica CORS, SameSite, HTTPS, y un workflow que la primera vez duele.

Para un proyecto de aprendizaje/portfolio o app interna, puedes arrancar con localStorage sabiendo el tradeoff. Si el plan es ir a producción con gente pagando o datos sensibles, planea el salto a cookies HttpOnly.

Ejemplo rápido: request con Authorization

// api.ts
export async function apiGet(path: string) {
  const access = localStorage.getItem('accessToken');

  const res = await fetch(`${import.meta.env.VITE_API_URL}${path}`, {
    headers: access ? { Authorization: `Bearer ${access}` } : {}
  });

  if (!res.ok) throw new Error(await res.text());
  return res.json();
}

Escena real: el internet falla, el backend en tu laptop se duerme, y el usuario se queda viendo “cargando…” hasta el infinito. Si recibes 401, redirige al login y ya. Sin pantallas blancas, sin loops raros.

Screenshots sugeridos

  • Postman/Insomnia: POST /api/auth/login mostrando accessToken y refreshToken.
  • Decodificación del JWT (jwt.io) para ver sub, email, uid.
  • Request a GET /api/me con header Authorization: Bearer ....
  • Un 401 real cuando el token expiró.
  • Registros en MySQL: filas en users y refresh_tokens.

Errores comunes + solución

1) 401 aunque “acabo de loguearme”

Causa típica: no estás mandando Authorization: Bearer <token> o mandaste el refresh token por error.

Solución: imprime el token que usas, revisa Network tab y confirma que el endpoint tiene [Authorize].

2) IDX10214: Audience validation failed

Causa: JwtAudience no coincide entre emisión y validación.

Solución: revisa JwtIssuer y JwtAudience en appsettings y que estés leyendo el environment correcto en deploy.

3) El token “expira antes” (o “todavía no es válido”)

Causa: reloj desfasado entre server y tu máquina, o zonas horarias mal entendidas.

Solución: usa DateTime.UtcNow y deja ClockSkew pequeño (30s). En servers baratos con Linux, un NTP mal configurado es más común de lo que debería.

4) Guardaste el password sin hash (sí pasa)

Causa: “luego lo arreglo” y se fue a staging.

Solución: migra ya. No hay parche bonito. Hashea y fuerza reset de passwords si ya hay usuarios reales.

5) Refresh token infinito (sesiones que nunca mueren)

Causa: refresh token con expiración gigante y sin revocación/rotación.

Solución: expira en días (no años), rota en cada refresh, guarda revocación en DB. Y si el usuario hace logout, revoca.

Login, sesiones y tokens: quién eres y por qué te creo - visual explicativa 2
Visual de apoyo: Qué vas a aprender

Checklist final

  • Passwords con hash (PasswordHasher) y nunca en texto plano.
  • AccessTokenMinutes corto (10–20 min es buen inicio).
  • Refresh token con expiración y guardado hasheado en DB.
  • Rotación de refresh token en /refresh.
  • Middleware correcto: UseAuthentication() antes de UseAuthorization().
  • Endpoints sensibles con [Authorize].
  • Secrets fuera del repo (variables de entorno en deploy).
  • SQL scripts versionados en el proyecto.

FAQ

1) ¿Por qué no usar solo JWT y ya?

Porque si se filtra un JWT no lo puedes “matar” fácil hasta que expire. Con refresh + rotación tienes control real para responder a incidentes.

2) ¿Cuánto debe durar el access token?

Corto. 15 minutos es un buen default. Si manejas pagos o datos sensibles, bájalo. Si es app interna, puedes subirlo un poco, pero con refresh bien armado.

3) ¿Dónde guardo tokens en React sin comprometer seguridad?

Lo más seguro suele ser refresh en cookie HttpOnly y access en memoria. Si guardas en localStorage, asume el riesgo ante XSS y refuerza higiene: sanitización, CSP, y cero dangerouslySetInnerHTML sin control.

4) ¿Qué es “rotación” de refresh token y por qué importa?

Cada refresh revoca el token anterior y emite uno nuevo. Si alguien roba un refresh viejo, ya no sirve.

5) ¿Cómo hago logout bien?

Frontend: borra access/refresh. Backend: revoca el refresh token actual (marca revoked_at). Si quieres ser más duro, revoca todos los refresh tokens del usuario.

Siguiente episodio: teaser

Ya que tu API sabe quién eres, toca el siguiente pleito: permisos.
Roles, claims y autorización fina: “sí eres tú, pero… ¿puedes hacer esto?”