Skip to main content

Overview

The AuthService implements defense-in-depth security, with multiple layers of protection against common authentication attacks. Every design decision prioritizes security over convenience.
This service follows OWASP guidelines and NIST standards for authentication security.

Password Security

PBKDF2 Password Hashing

Passwords are hashed using PBKDF2-HMAC-SHA512, the NIST-recommended algorithm for password storage. Implementation (PasswordService.cs:17-37):
public class PasswordService : IPasswordService
{
    private const int SaltSize = 32;       // 256 bits
    private const int HashSize = 64;       // 512 bits
    private const int Iterations = 600_000; // OWASP 2024 recommendation
    private static readonly HashAlgorithmName Algorithm = HashAlgorithmName.SHA512;

    public string Hash(string password)
    {
        var salt = RandomNumberGenerator.GetBytes(SaltSize);
        var hash = Rfc2898DeriveBytes.Pbkdf2(
            Encoding.UTF8.GetBytes(password),
            salt,
            Iterations,
            Algorithm,
            HashSize
        );

        // Format: iterations:salt_base64:hash_base64
        return $"{Iterations}:{Convert.ToBase64String(salt)}:{Convert.ToBase64String(hash)}";
    }
}
Why these parameters?

600,000 Iterations

OWASP 2024 recommendation for PBKDF2-SHA512Makes brute-force attacks computationally expensive. Each password attempt takes ~100ms to verify.At 600k iterations, cracking a 10-character password would take centuries with current hardware.

SHA-512 Algorithm

512-bit output provides collision resistanceStronger than SHA-256, recommended by NIST SP 800-132 for password hashing.More resistant to GPU/ASIC attacks than MD5 or SHA-1.

32-byte Random Salt

Unique per password prevents rainbow table attacksGenerated using cryptographically secure RNG (PasswordService.cs:26).Stored alongside hash in format: iterations:salt:hash

64-byte Hash Output

512-bit hash maximizes securityLonger than typical 256-bit hashes, providing extra collision resistance.Hash + salt together make precomputed attacks infeasible.
Why PBKDF2 over bcrypt or Argon2?
AlgorithmProsConsAuthService Choice
PBKDF2NIST-approved, built into .NET, configurable iterationsSlightly more vulnerable to GPU attacksUsed here
bcryptIndustry standard, GPU-resistantFixed cost factor (2^n), limited to 72-byte passwords❌ Less control
Argon2Winner of Password Hashing Competition, memory-hardNot in .NET Core, requires native library❌ External dependency
Decision: PBKDF2 with 600k iterations provides excellent security without external dependencies. The comment in PasswordService.cs:12-15 explains:
/// No uso BCrypt porque quiero control explícito sobre los parámetros
/// y PBKDF2 es el estándar recomendado por NIST SP 800-132.

Timing Attack Prevention

Password verification uses constant-time comparison to prevent timing attacks (PasswordService.cs:57-58):
// Comparación en tiempo constante para evitar timing attacks
return CryptographicOperations.FixedTimeEquals(actualHash, expectedHash);
Why this matters:
1

Without constant-time comparison

A naive comparison like actualHash == expectedHash exits early on the first mismatched byte.Attacker can measure response times:
  • Fast response → first byte wrong
  • Slower response → more bytes correct
This leaks information about the hash!
2

With constant-time comparison

FixedTimeEquals always compares all bytes, regardless of matches.Response time is constant:
  • Wrong password → same time
  • Correct password → same time
No information leaked.

Account Lockout Protection

Brute-Force Prevention

After 5 failed login attempts, accounts are locked for 15 minutes (AuthService.cs:25-27):
// Después de 5 intentos fallidos, bloquear por 15 minutos
private const int MaxFailedAttempts = 5;
private const int LockoutMinutes = 15;
Implementation (AuthService.cs:88-100):
if (!_passwordService.Verify(request.Password, user.PasswordHash))
{
    user.FailedLoginAttempts++;

    if (user.FailedLoginAttempts >= MaxFailedAttempts)
    {
        user.LockoutEnd = DateTime.UtcNow.AddMinutes(LockoutMinutes);
        _logger.LogWarning("Cuenta bloqueada por intentos fallidos: {Email}", user.Email);
    }

    await _db.SaveChangesAsync();
    throw new UnauthorizedAccessException("Credenciales inválidas.");
}
Lockout check (AuthService.cs:81-86):
if (user.IsLockedOut())
{
    var remaining = (int)(user.LockoutEnd!.Value - DateTime.UtcNow).TotalMinutes + 1;
    throw new UnauthorizedAccessException(
        $"Cuenta bloqueada temporalmente. Intenta en {remaining} minuto(s).");
}
The IsLockedOut method (User.cs:20-21):
public bool IsLockedOut() =>
    LockoutEnd.HasValue && LockoutEnd.Value > DateTime.UtcNow;
Attack mitigation:
  • Credential stuffing: Limits attacker to 5 attempts per account
  • Brute-force: Makes large-scale attacks impractical (15min × attempts = weeks)
  • Dictionary attacks: Rate-limits password guessing
Lockout counters are persistent (stored in database). Restarting the server doesn’t reset them.

Lockout Reset on Success

When a user logs in successfully, the lockout counter is reset (AuthService.cs:102-105):
// Login exitoso -- resetear contadores
user.FailedLoginAttempts = 0;
user.LockoutEnd = null;
user.LastLoginAt = DateTime.UtcNow;
This ensures legitimate users aren’t permanently locked out after forgetting their password.

Information Disclosure Prevention

Generic Error Messages

The service returns identical error messages for different failure scenarios to prevent username enumeration. Email doesn’t exist (AuthService.cs:72-76):
if (user == null)
{
    // Respuesta genérica para no revelar si el email existe
    throw new UnauthorizedAccessException("Credenciales inválidas.");
}
Wrong password (AuthService.cs:88-100):
if (!_passwordService.Verify(request.Password, user.PasswordHash))
{
    // ... increment counter ...
    throw new UnauthorizedAccessException("Credenciales inválidas.");
}
Inactive account (AuthService.cs:78-79):
if (!user.IsActive)
    throw new UnauthorizedAccessException("Cuenta desactivada.");
All return the same HTTP 401 status with generic messages. An attacker cannot determine:
  • Whether an email is registered
  • Whether the password is correct for an existing account
  • Whether an account is active or disabled
Exception: Account lockout messages are specific (“Cuenta bloqueada…”) because at that point, the attacker already knows the account exists (they triggered 5 lookups).

Centralized Error Handling

All exceptions are caught and normalized by middleware (ExceptionHandlingMiddleware.cs:29-38):
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
{
    var (statusCode, message) = exception switch
    {
        UnauthorizedAccessException => (HttpStatusCode.Unauthorized, exception.Message),
        InvalidOperationException => (HttpStatusCode.Conflict, exception.Message),
        KeyNotFoundException => (HttpStatusCode.NotFound, exception.Message),
        ArgumentException => (HttpStatusCode.BadRequest, exception.Message),
        _ => (HttpStatusCode.InternalServerError, "Ocurrió un error interno.")
    };
    // ...
}
This ensures:
  • Consistent error format across all endpoints
  • No stack traces leaked in production
  • Internal errors return generic “500” message
  • Security-relevant errors are logged (ExceptionHandlingMiddleware.cs:40-41)

Token Security

Refresh Token Rotation

See the dedicated Token Rotation page for details. Key points:
  • Single-use tokens: Each refresh invalidates the previous token
  • Reuse detection: Using an old token triggers security response
  • Family revocation: All descendant tokens are invalidated on breach
Implemented in AuthService.cs:114-152.

Cryptographically Secure Token Generation

Refresh tokens are generated using RandomNumberGenerator (TokenService.cs:62-66):
public string GenerateRefreshToken()
{
    var bytes = RandomNumberGenerator.GetBytes(64);
    return Convert.ToBase64String(bytes);
}
  • 64 bytes of entropy (512 bits) makes tokens unguessable
  • Base64 encoding for safe transport in JSON/HTTP
  • Cryptographically secure RNG (not pseudo-random)
Never use Random() for token generation—it’s predictable and can be reverse-engineered.

JWT Security

Access tokens are signed JWTs using HMAC-SHA256 (TokenService.cs:26-56):
var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(secretKey));
var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256);

var token = new JwtSecurityToken(
    issuer: jwtSection["Issuer"],
    audience: jwtSection["Audience"],
    claims: claims,
    notBefore: DateTime.UtcNow,
    expires: DateTime.UtcNow.AddMinutes(expiryMinutes),
    signingCredentials: creds
);
Security properties:
  • HMAC-SHA256 signature: Prevents token tampering
  • Issuer validation: Rejects tokens from other sources
  • Audience validation: Ensures tokens are for this service
  • Expiry validation: Rejects expired tokens (15 minutes)
  • Not-before validation: Rejects tokens used too early
Signature verification (TokenService.cs:68-102) validates:
if (validatedToken is not JwtSecurityToken jwtToken ||
    !jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256, StringComparison.OrdinalIgnoreCase))
{
    return null; // Reject tokens with wrong algorithm
}
This prevents algorithm substitution attacks where an attacker changes the algorithm from HMAC to “none”.

IP Address Tracking

Forensic Data Collection

The service tracks IP addresses for all authentication events (AuthController.cs:81-84):
private string GetClientIp()
{
    return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
}
IPs are recorded for:
  • Registration: CreateRefreshToken(ipAddress) (AuthService.cs:190)
  • Login: CreateRefreshToken(ipAddress) (AuthService.cs:190)
  • Token refresh: CreatedByIp on new token (RefreshToken.cs:12)
  • Token revocation: RevokedByIp (AuthService.cs:140, 164)
Stored in database (RefreshToken.cs:12-17):
public string? CreatedByIp { get; set; }    // Where token was created
public string? RevokedByIp { get; set; }    // Who revoked it
Use cases:
  • Detect logins from unusual locations
  • Correlate token reuse attacks with IP addresses
  • Forensic analysis after breach detection
  • Identify compromised devices
IP addresses are not used for authorization—only for logging and forensics. Authorization is purely token-based.

Account Status Controls

Active/Inactive Flag

Accounts can be disabled without deletion (User.cs:14):
public bool IsActive { get; set; } = true;
Checked during login (AuthService.cs:78-79):
if (!user.IsActive)
    throw new UnauthorizedAccessException("Cuenta desactivada.");
Use cases:
  • Temporarily disable suspicious accounts
  • Soft-delete users (preserve data, revoke access)
  • Implement “account suspension” feature
  • Comply with GDPR “right to erasure” (mark inactive, delete later)

Activity Timestamps

User entity tracks important dates (User.cs:15-16):
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime? LastLoginAt { get; set; }  // Updated on successful login
LastLoginAt is updated on successful login (AuthService.cs:105):
user.LastLoginAt = DateTime.UtcNow;
Use cases:
  • Identify dormant accounts (no login in 90+ days)
  • Show “last login” to users for security awareness
  • Detect compromised accounts (sudden activity after long dormancy)
  • Compliance reporting (active user counts)

Token Lifecycle Management

Automatic Token Cleanup

Old tokens are automatically purged to prevent database bloat (AuthService.cs:224-233):
private static void RemoveOldTokens(User user)
{
    var cutoff = DateTime.UtcNow.AddDays(-30);
    var oldTokens = user.RefreshTokens
        .Where(rt => !rt.IsActive && rt.CreatedAt < cutoff)
        .ToList();

    foreach (var token in oldTokens)
        user.RefreshTokens.Remove(token);
}
Policy:
  • Active tokens: Never deleted (needed for auth)
  • Inactive tokens < 30 days: Kept (forensics)
  • Inactive tokens > 30 days: Deleted (space management)
Runs on every refresh (AuthService.cs:147) and registration/login (AuthService.cs:193).

Revocation Tracking

When tokens are revoked, metadata is preserved (RefreshToken.cs:15-18):
public DateTime? RevokedAt { get; set; }       // When
public string? RevokedByIp { get; set; }       // Where
public string? ReplacedByToken { get; set; }   // What replaced it
public string? RevocationReason { get; set; }  // Why
Revocation reasons:
  • "Rotación normal" - Token rotated during refresh (AuthService.cs:142)
  • "Token reuse detectado" - Security event triggered (AuthService.cs:129, 247)
  • "Revocación manual" - User logged out (AuthService.cs:165)
  • "User logout" - Explicit logout by user (AuthController.cs:63)
This audit trail enables post-incident analysis.

Input Validation

Request DTOs

All inputs are validated using data annotations (AuthDTOs.cs:5-23):
public record RegisterRequest(
    [Required, MaxLength(100)] string Username,
    [Required, EmailAddress, MaxLength(256)] string Email,
    [Required, MinLength(8), MaxLength(128)] string Password
);

public record LoginRequest(
    [Required, EmailAddress] string Email,
    [Required] string Password
);

public record RefreshTokenRequest(
    [Required] string RefreshToken
);
Validations applied:
  • Email format: [EmailAddress] ensures valid email syntax
  • Password length: 8-128 characters (prevents empty and overly long passwords)
  • Username length: Max 100 characters (prevents buffer attacks)
  • Required fields: All fields are mandatory
Validation happens automatically via ASP.NET Core model binding. Invalid requests return 400 Bad Request before reaching controller code.

Email Normalization

Emails are normalized to lowercase to prevent duplicate accounts (AuthService.cs:47, 55, 70):
var existingUser = await _db.Users
    .FirstOrDefaultAsync(u => u.Email == request.Email.ToLower());

// ...

var user = new User
{
    Email = request.Email.ToLower(),
    // ...
};
This ensures:
  • John@Example.com and john@example.com are treated as the same account
  • No duplicate registrations due to case differences
  • Consistent lookup behavior

Logging and Monitoring

Security Event Logging

Critical security events are logged (AuthService.cs:23):
private readonly ILogger<AuthService> _logger;
Logged events:
  1. Successful registration (AuthService.cs:62):
    _logger.LogInformation("Nuevo usuario registrado: {Email}", user.Email);
    
  2. Account lockout (AuthService.cs:95):
    _logger.LogWarning("Cuenta bloqueada por intentos fallidos: {Email}", user.Email);
    
  3. Successful login (AuthService.cs:109):
    _logger.LogInformation("Login exitoso: {Email} desde {Ip}", user.Email, ipAddress);
    
  4. Token reuse attack (AuthService.cs:127-128):
    _logger.LogWarning(
        "Posible token reuse attack detectado para usuario {UserId}", storedToken.UserId);
    
  5. Token revocation (AuthService.cs:169):
    _logger.LogInformation("Token revocado para usuario {UserId}", storedToken.UserId);
    
  6. Unhandled exceptions (ExceptionHandlingMiddleware.cs:40-41):
    if (statusCode == HttpStatusCode.InternalServerError)
        _logger.LogError(exception, "Error no controlado");
    
Log levels used:
  • Information: Normal operations (registration, login, revocation)
  • Warning: Security events (lockout, token reuse)
  • Error: System failures (unhandled exceptions)
In production, configure log aggregation (e.g., Serilog + Seq, ELK stack) to monitor these events in real-time.

What NOT to Log

The service never logs:
  • Passwords (plaintext or hashed)
  • Token values (access or refresh)
  • Full request/response bodies
  • User personal data beyond email
Logging these would create security risks and GDPR compliance issues.

Database Security

No Plaintext Secrets

Passwords are never stored in plaintext. Only PBKDF2 hashes are persisted (User.cs:8):
public string PasswordHash { get; set; } = string.Empty;
Even if the database is compromised, attackers cannot directly obtain passwords.

Token Storage

Refresh tokens are stored verbatim in the database (RefreshToken.cs:7):
public string Token { get; set; } = string.Empty;
This is acceptable because:
  • Tokens are cryptographically random (512 bits)
  • They’re single-use (rotation)
  • Database access requires authentication
  • Tokens have limited lifetime (7 days)
For ultra-high-security environments, consider hashing refresh tokens before storage. Trade-off: adds complexity and one additional DB query per refresh.

Security Headers

While not explicitly shown in the code, production deployments should add security headers:
// In Program.cs or middleware
app.Use(async (context, next) =>
{
    context.Response.Headers.Add("X-Content-Type-Options", "nosniff");
    context.Response.Headers.Add("X-Frame-Options", "DENY");
    context.Response.Headers.Add("X-XSS-Protection", "1; mode=block");
    context.Response.Headers.Add("Strict-Transport-Security", "max-age=31536000");
    await next();
});

Security Checklist

1

Password Security

✅ PBKDF2-SHA512 with 600k iterations ✅ 32-byte random salt per password ✅ Constant-time comparison ✅ Minimum 8-character requirement
2

Authentication Security

✅ Account lockout after 5 failed attempts ✅ Generic error messages (no enumeration) ✅ JWT signature validation ✅ Token expiration enforcement
3

Token Security

✅ Refresh token rotation ✅ Reuse attack detection ✅ Cryptographically secure generation ✅ IP address tracking
4

Operational Security

✅ Comprehensive logging ✅ Automatic token cleanup ✅ Account status controls ✅ Centralized error handling

Threat Model Coverage

Attack VectorMitigationImplementation
Brute-force loginAccount lockout (5 attempts, 15min)AuthService.cs:88-96
Credential stuffingRate limiting via lockoutAuthService.cs:88-96
Rainbow tableUnique salt per passwordPasswordService.cs:26
Timing attackConstant-time comparisonPasswordService.cs:58
Token theftRotation + reuse detectionAuthService.cs:114-152
Token tamperingHMAC-SHA256 signatureTokenService.cs:31
Username enumerationGeneric error messagesAuthService.cs:72-76
Session hijackingShort-lived access tokens (15min)TokenService.cs:44
XSS token theftHttpOnly cookies (client responsibility)N/A
CSRFToken-based auth (no cookies for auth)N/A
SQL injectionEntity Framework parameterizationEntire codebase
Replay attacksUnique JTI per tokenTokenService.cs:38
For even stronger security, consider adding:
  1. Rate limiting - Throttle requests per IP (use middleware like AspNetCoreRateLimit)
  2. CAPTCHA - After 3 failed attempts, require CAPTCHA
  3. 2FA/MFA - Add TOTP support for high-value accounts
  4. Device fingerprinting - Detect token use from new devices
  5. Geolocation - Alert users to logins from unusual locations
  6. Password strength meter - Enforce stronger passwords (zxcvbn)
  7. Breach detection - Check passwords against HaveIBeenPwned
  8. Security questions - For account recovery
  9. Email verification - Verify email ownership on registration
  10. Audit log - Comprehensive activity log per user

Compliance Considerations

GDPR

Right to erasure: Set IsActive = false and delete tokensData portability: Export user data via /api/auth/meBreach notification: Use logs to detect and report breaches within 72 hours

PCI DSS

Requirement 8: Strong authentication (PBKDF2 600k iterations)Requirement 10: Comprehensive audit loggingRequirement 6: Secure development (input validation, error handling)

NIST 800-63B

Level AAL2: Password + token-based authPassword storage: PBKDF2 with 600k iterations (compliant)Token lifetimes: 15min access, 7-day refresh (compliant)

OWASP Top 10

A01 Broken Access Control: JWT validation enforcedA02 Cryptographic Failures: Strong hashing, secure RNGA07 Auth Failures: Lockout, rotation, generic errors

Authentication Flow

See how these features integrate into the complete flow

Token Rotation

Deep dive into refresh token security

API Reference

Complete endpoint documentation