Published on

Why API Request Signatures Outshine API Keys

Authors

TL;DR

  • Non-repudiation: The server can prove that a request was made by a specific client.
  • No Secret Transmission: The client does not need to send the secret key with the request.
  • Replay Protection: Time-bound requests prevent replay attacks from being successful.
  • Request Integrity: The server can verify that the request has not been tampered with while in transit.

API keys are like those "uncrackable" diary locks you had in school — fancy and adorable but useless. They're static, easily stolen, and once they're out in the wild, it's game over. Anyone can take that key and wreak havoc faster than you can say data breach. Almost like leaving your front door key under the welcome mat with a neon sign pointing to it.

Enter API Request Signatures. Each request is signed with a unique signature, involving timestamps, nonces, and cryptographic algorithms. Even if some sneaky snooper gets their hands on your request [man-in-the-middle style], they're practially useless to the attacker at deployment time. From mitiating replay attacks to request non-repudiation, the benefits of using request signatures over simple transmitted keys are significant.

How?

First we sign the request using some cryptographic hash algorithm. Here, I construct a HMAC instance using my secret key which could be coming in hot from my env or a secrets manager like Azure Key Vault and compute the hash using the SHA256 function [can be replaced with other hash functions like those from the Keccak/SHA3 subset]:

public sealed class ApiSigner(string secretKey)
{
    public string SignRequest(HttpMethod method, string endpoint, string timestamp, string? body = default)
    {
        var stringToSign = $"{method.Method.ToUpperInvariant()}\n{endpoint}\n{timestamp}\n{body ?? string.Empty}";
        
        using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(secretKey));
        var signatureBytes = hmac.ComputeHash(Encoding.UTF8.GetBytes(stringToSign));
        return Convert.ToBase64String(signatureBytes);
    }
}

Then we send our signed request to the server:

async Task<ApiResponse> SendSignedRequest(JsonNode payload)
{
    const string endpoint = "/api/data";
    var timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString();
    var signature = signer.SignRequest(HttpMethod.Post, endpoint, timestamp, payload.ToJsonString());

    using var request = new HttpRequestMessage(HttpMethod.Post, endpoint)
    {
        Headers =
        {
            {"X-Client-Id", credentials.ClientId},
            {"X-Timestamp", timestamp},
            {"X-Signature", signature}
        },
        Content = JsonContent.Create(payload)
    };

    var response = await client.SendAsync(request);
    return await response.Content.ReadFromJsonAsync<ApiResponse>();
}

Finally, the server verifies the request signature:

app.MapPost("/api/data", async (HttpContext context) =>
{
    try
    {
        var clientId = context.Request.Headers.GetHeader("X-Client-Id");
        var timestamp = context.Request.Headers.GetHeader("X-Timestamp");
        var signature = context.Request.Headers.GetHeader("X-Signature");
        
        if (!clientCredentials.TryGetValue(clientId, out var secretKey))
        {
            return Results.Json(
                new ApiResponse(Success: false, Message: "Invalid client id"),
                statusCode: StatusCodes.Status401Unauthorized);
        }

        using var reader = new StreamReader(context.Request.Body);
        var body = await reader.ReadToEndAsync();

        var requestTime = DateTimeOffset.FromUnixTimeSeconds(long.Parse(timestamp));
        if (Math.Abs((DateTimeOffset.UtcNow - requestTime).TotalMinutes) > 5)
        {
            return Results.Json(
                new ApiResponse(Success: false, Message: "Request expired"),
                statusCode: StatusCodes.Status401Unauthorized);
        }

        var signer = new ApiSigner(secretKey);
        var expectedSignature = signer.SignRequest(
            HttpMethod.Post,
            context.Request.Path,
            timestamp,
            body);

        if (signature != expectedSignature)
        {
            return Results.Json(
                new ApiResponse(Success: false, Message: "Invalid signature"),
                statusCode: StatusCodes.Status401Unauthorized);
        }

        return Results.Json(new ApiResponse(
            Success: true,
            Message: "Data processed successfully",
            Data: JsonNode.Parse(body)),
            statusCode: StatusCodes.Status200OK);
    }
    catch (UnauthorizedAccessException ex)
    {
        return Results.Json(
            new ApiResponse(Success: false, Message: ex.Message),
            statusCode: StatusCodes.Status401Unauthorized);
    }
    catch (Exception)
    {
        return Results.Json(
            new ApiResponse(Success: false, Message: "Internal server error"),
            statusCode: StatusCodes.Status500InternalServerError);
    }
});

The key advantage of request signing isn't that we don't need to store secrets - it's that we never transmit them. Even if an attacker intercepts the request:

  • they can't forge a new one without the secret key
  • signatures can't be reused due to the embedded timestamp [and nonce if you're feeling fancy]
  • they can't modify the request [signature would obviously be invalid]

The full code for this example can be found in the GitHub repository.