16. Custom API Key
- 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:
- Never Store Raw Keys: Only hash
- Use Strong Hashing: SHA256 minimum (or better: Argon2, bcrypt)
- Add Salt: Even if hashing algorithm is broken
- Unique Keys: GUIDs ensure uniqueness
- Rate Limiting: Prevent brute force attacks
- Expiration: Consider time-limited keys
- 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");
}
}