16. Custom API Key

  1. Custom API Key

About this chapter

Build custom API key authentication as an alternative to JWT, implementing secure key generation, hashing, and validation for server-to-server communication.

  • Custom authentication handlers: Creating AuthenticationHandler implementations
  • API key generation: Generating and storing secure keys with hashing
  • Key validation: Verifying provided keys against stored hashes
  • Constants and magic numbers: Using constants for maintainability
  • Security best practices: Hashing, salting, and protecting keys
  • Key management endpoints: Creating, listing, and revoking API keys

Learning outcomes:

  • Implement custom authentication handlers for API keys
  • Generate secure API keys with unique identifiers
  • Hash API keys using SHA256 and salt values
  • Create endpoints for API key registration and management
  • Understand security principles for API authentication
  • Prevent brute force attacks with validation and logging

16.1 Creating Custom Authentication Handler

using Microsoft.AspNetCore.Authentication;
using Microsoft.Extensions.Options;
using System.Security.Claims;
using System.Text.Encodings.Web;

namespace CommandAPI.Security;

public class ApiKeyAuthenticationHandler : AuthenticationHandler<AuthenticationSchemeOptions>
{
    private const string ApiKeyHeaderName = "x-api-key";
    private readonly IRegistrationRepository _registrationRepo;
    private readonly ILogger<ApiKeyAuthenticationHandler> _logger;

    public ApiKeyAuthenticationHandler(
        IOptionsMonitor<AuthenticationSchemeOptions> options,
        ILoggerFactory loggerFactory,
        UrlEncoder encoder,
        IRegistrationRepository registrationRepo)
        : base(options, loggerFactory, encoder)
    {
        _registrationRepo = registrationRepo;
        _logger = loggerFactory.CreateLogger<ApiKeyAuthenticationHandler>();
    }

    protected override async Task<AuthenticateResult> HandleAuthenticateAsync()
    {
        // Check if header exists
        if (!Request.Headers.TryGetValue(ApiKeyHeaderName, out var apiKeyValues))
        {
            _logger.LogDebug("API key header not found");
            return AuthenticateResult.NoResult();
        }

        var providedApiKey = apiKeyValues.FirstOrDefault();

        // Validate key format
        if (string.IsNullOrWhiteSpace(providedApiKey) || 
            providedApiKey.Length < ApiKeyConstants.MinimumKeyLength)
        {
            _logger.LogWarning("Invalid API key format provided");
            return AuthenticateResult.Fail("Invalid API key format");
        }

        // Validate key and get user
        var userId = await ValidateKeyAsync(providedApiKey);

        if (string.IsNullOrEmpty(userId))
        {
            _logger.LogWarning("API key validation failed");
            return AuthenticateResult.Fail("Invalid API key");
        }

        // Create claims principal
        var claims = new[]
        {
            new Claim(ClaimTypes.NameIdentifier, userId),
            new Claim(ClaimTypes.Name, "ApiKeyUser"),
            new Claim("auth_method", "api_key")
        };
        var identity = new ClaimsIdentity(claims, Scheme.Name);
        var principal = new ClaimsPrincipal(identity);
        var ticket = new AuthenticationTicket(principal, Scheme.Name);

        _logger.LogInformation("API key authentication succeeded for user: {UserId}", userId);
        return AuthenticateResult.Success(ticket);
    }

    private async Task<string?> ValidateKeyAsync(string providedKey)
    {
        try
        {
            // Extract key index (first 36 characters = GUID)
            var keyIndex = providedKey[..ApiKeyConstants.KeyIndexLength];

            // Look up registration
            var registration = await _registrationRepo.GetRegistrationByIndex(keyIndex);
            if (registration == null)
            {
                return null;
            }

            // Hash provided key and compare
            var hashedKey = HashApiKey(providedKey);

            if (hashedKey != registration.KeyHash)
            {
                return null;
            }

            return registration.UserId;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error validating API key");
            return null;
        }
    }

    private static string HashApiKey(string apiKey)
    {
        using var sha256 = SHA256.Create();
        var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(apiKey));
        return Convert.ToBase64String(hashBytes);
    }
}

16.2 Implementing API Key Generation

[Route("api/[controller]")]
[ApiController]
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
public class RegistrationsController : ControllerBase
{
    private readonly IRegistrationRepository _registrationRepo;
    private readonly ILogger<RegistrationsController> _logger;

    public RegistrationsController(
        IRegistrationRepository registrationRepo,
        ILogger<RegistrationsController> logger)
    {
        _registrationRepo = registrationRepo;
        _logger = logger;
    }

    [HttpPost]
    [ProducesResponseType(StatusCodes.Status200OK)]
    [ProducesResponseType(StatusCodes.Status400BadRequest)]
    public async Task<ActionResult<ApiKeyResponse>> RegisterKey(
        KeyRegistrationMutateDto registrationDto)
    {
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (string.IsNullOrEmpty(userId))
        {
            return BadRequest(new { message = "User ID not found in token" });
        }

        // Generate unique key components
        var keyIndex = Guid.NewGuid();
        var apiKeySecret = Guid.NewGuid();
        var fullApiKey = $"{keyIndex}{apiKeySecret}";  // 72 characters total

        // Generate salt for additional security
        var salt = Guid.NewGuid();

        // Hash the full API key
        var keyHash = HashApiKey(fullApiKey);

        // Store registration
        var registration = new KeyRegistration
        {
            KeyIndex = keyIndex,
            Salt = salt,
            KeyHash = keyHash,
            Description = registrationDto.Description,
            UserId = userId,
            CreatedAt = DateTime.UtcNow
        };

        await _registrationRepo.CreateRegistrationAsync(registration);
        await _registrationRepo.SaveChangesAsync();
        _logger.LogInformation("API key registered for user: {UserId}", userId);

        // Return key ONCE (cannot be retrieved again)
        return Ok(new ApiKeyResponse
        {
            ApiKey = fullApiKey,
            KeyIndex = keyIndex.ToString(),
            Description = registrationDto.Description,
            Message = "Save this key securely. It cannot be displayed again.",
            CreatedAt = registration.CreatedAt
        });
    }

    [HttpGet]
    [ProducesResponseType(StatusCodes.Status200OK)]
    public async Task<ActionResult<IEnumerable<KeyRegistrationReadDto>>> GetMyKeys()
    {
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        var keys = await _registrationRepo.GetKeysByUserIdAsync(userId!);

        return Ok(keys.Select(k => new KeyRegistrationReadDto
        {
            KeyIndex = k.KeyIndex.ToString(),
            Description = k.Description,
            CreatedAt = k.CreatedAt,
            LastUsedAt = k.LastUsedAt
        }));
    }

    [HttpDelete("{keyIndex}")]
    [ProducesResponseType(StatusCodes.Status204NoContent)]
    [ProducesResponseType(StatusCodes.Status404NotFound)]
    public async Task<ActionResult> DeleteKey(string keyIndex)
    {
        var registration = await _registrationRepo.GetRegistrationByIndex(keyIndex);

        if (registration == null)
        {
            return NotFound();
        }

        // Verify ownership
        var userId = User.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (registration.UserId != userId)
        {
            return Forbid();  // 403
        }

        _registrationRepo.DeleteRegistration(registration);
        await _registrationRepo.SaveChangesAsync();
        _logger.LogInformation("API key deleted: {KeyIndex}", keyIndex);

        return NoContent();
    }

    private static string HashApiKey(string apiKey)
    {
        using var sha256 = SHA256.Create();
        var hashBytes = sha256.ComputeHash(Encoding.UTF8.GetBytes(apiKey));
        return Convert.ToBase64String(hashBytes);
    }
}

16.3 CORRECTION: Using Constants Instead of Magic Numbers

// ❌ WRONG (current code)
if (providedApiKey.Length < 36)  // What is 36?

// ✅ CORRECT
namespace CommandAPI.Security;

public static class ApiKeyConstants
{
    public const int KeyIndexLength = 36;  // GUID string length
    public const int ApiKeySecretLength = 36;  // Second GUID
    public const int TotalKeyLength = 72;  // Both combined
    public const int MinimumKeyLength = 72;
    
    public const string HeaderName = "x-api-key";
    public const string SchemeName = "ApiKey";
}

// Usage
if (providedApiKey.Length < ApiKeyConstants.TotalKeyLength)
{
    return AuthenticateResult.Fail(
        $"API key must be {ApiKeyConstants.TotalKeyLength} characters");
}

var keyIndex = providedApiKey[..ApiKeyConstants.KeyIndexLength];

Why Constants Matter:

  • Maintainability: Change in one place
  • Readability: Self-documenting code
  • Type Safety: Compile-time checking
  • Consistency: Same values everywhere

16.4 Hashing and Securing API Keys

public class KeyRegistration
{
    public int Id { get; set; }
    
    // Index: Stored in plaintext for lookup
    public Guid KeyIndex { get; set; }
    
    // Salt: Additional randomness (defense in depth)
    public Guid Salt { get; set; }
    
    // Hash: One-way hash of full key
    public required string KeyHash { get; set; }
    
    // Metadata
    public string? Description { get; set; }
    public required string UserId { get; set; }
    public DateTime CreatedAt { get; set; }
    public DateTime? LastUsedAt { get; set; }
}

Security Best Practices:

  1. Never Store Raw Keys: Only hash
  2. Use Strong Hashing: SHA256 minimum (or better: Argon2, bcrypt)
  3. Add Salt: Even if hashing algorithm is broken
  4. Unique Keys: GUIDs ensure uniqueness
  5. Rate Limiting: Prevent brute force attacks
  6. Expiration: Consider time-limited keys
  7. Audit Logging: Track key usage

Enhanced Security (optional):

// Use Argon2 instead of SHA256 for password-level security
using Konscious.Security.Cryptography;

private static string HashApiKeyWithArgon2(string apiKey, byte[] salt)
{
    using var argon2 = new Argon2id(Encoding.UTF8.GetBytes(apiKey))
    {
        Salt = salt,
        DegreeOfParallelism = 8,
        Iterations = 4,
        MemorySize = 65536  // 64 MB
    };

    var hashBytes = argon2.GetBytes(32);
    return Convert.ToBase64String(hashBytes);
}

16.5 Database Storage of Keys

// Migration for KeyRegistration table
public partial class AddKeyRegistration : Migration
{
    protected override void Up(MigrationBuilder migrationBuilder)
    {
        migrationBuilder.CreateTable(
            name: "KeyRegistrations",
            columns: table => new
            {
                Id = table.Column<int>(nullable: false)
                    .Annotation("Npgsql:ValueGenerationStrategy",
                        NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
                KeyIndex = table.Column<Guid>(nullable: false),
                Salt = table.Column<Guid>(nullable: false),
                KeyHash = table.Column<string>(maxLength: 256, nullable: false),
                Description = table.Column<string>(maxLength: 500, nullable: true),
                UserId = table.Column<string>(maxLength: 128, nullable: false),
                CreatedAt = table.Column<DateTime>(nullable: false,
                    defaultValueSql: "CURRENT_TIMESTAMP"),
                LastUsedAt = table.Column<DateTime>(nullable: true)
            },
            constraints: table =>
            {
                table.PrimaryKey("PK_KeyRegistrations", x => x.Id);
            });

        // Create unique index on KeyIndex for fast lookups
        migrationBuilder.CreateIndex(
            name: "Index_KeyIndex",
            table: "KeyRegistrations",
            column: "KeyIndex",
            unique: true);

        // Create index on UserId for user's key list
        migrationBuilder.CreateIndex(
            name: "Index_UserId",
            table: "KeyRegistrations",
            column: "UserId");
    }
}