HMAC Security

Customers Bank uses HMAC security to sign all webhook events sent to the callbackURL specified in the webhook subscription. The HMAC signature is also hashed using HMAC-SHA256 with the secretText specified in the subscription. The callback URL needs to implement HMAC signature verification to ensure the authenticity of a webhook invocation.

The secretText should be Base64 encoded when subscribing. In the example below, the secret text my-secret has been Base64 encoded as bXktc2VjcmV0

Example Subscription:

POST /webhooks/v1/ HTTP/1.1
X-Idempotency-Key: 628bab13-6177-4ba5-b604-2fc8b9a0178f
Authorization: ****
Host: cubi-sandbox-api.customersbank.com

{
    "eventTypeName":"transfers.book-completed",
    "callbackUrl":"https://webhook.site/f57f777c-1274-41c4-aa97-af9e25782d6c",
    "secretText":"bXktc2VjcmV0",
    "description":"Transfers completed"
}

Example Callback (using the subscription example above):

POST /api/cubix/webhooks HTTP/1.1
X-Idempotency-Key: f553bece-a863-4061-b5f0-92f850c6b08a
X-Event-Type: transfers.book-completed
Authorization-Timestamp: Tue, 10 Sep 2024 13:10:32 GMT
Authorization: HMAC-SHA256 Signature=4OOstBbS4iOHeWEqnIF2nSOrG+9MKWsBVWCGDgU7CJk=
Host: webhook.site

{"Id":"4c1d8cc1-1ef6-411f-8078-b1e10139e992"}

HMAC Signature Verification

In order to verify the authenticity of an invocation on the callback URL, clients must reconstruct the HMAC signature and confirm it matches the signature value specified in the Authorization header of the callback. If the callback URL is not properly verifying the HMAC signature, the callback URL may be susceptible to malicious request attempts from third parties.

Constructing a valid HMAC signature

  1. Create a SHA256 hash of the request body content and convert the hash to a Base64 encoded string

  2. Construct a signature to be signed in the format:

    <callbackURL path and query>
    <Authorization-Timestamp>;<callbackUrl host>;<request body hash>
    
    1. <callBackUrl path and query> should be the absolute path and query properties specified in the callbackUrlin the webhook subscription
    2. <Authorization-Timestamp>should be the value of the request header Authorization-Timestamp
    3. <callbackUrl host> should be the host specified in the callbackUrl in the webhook subscription
    4. <request body hash>should be the BASE64 encoded SHA256 hash created in the first step

    Using the example callback above, the signature before signing would be:

    /f57f777c-1274-41c4-aa97-af9e25782d6c
    Tue, 10 Sep 2024 13:10:32 GMT;webhook.site;71MyZ3d9CKN7W9gnIXskBMB2zIWLAmMEM/j2qN3odnU=
    
  3. Hash the constructed signature by creating an HMAC-SHA256 hash using the same secretKey specified in the webhook subscription.

  4. The reconstructed hashed signature should match the value of the signature provided in the request Authorization header, if they do not match, the webhook callback should be rejected.

Example HMAC Verification

This is a naive verification example (hardcoded secret, etc.) meant for demonstration only. Using the example webhook callback payload above, you should be able to derive the same values in the example below.

[HttpPost]
public async Task<IActionResult> CubiWebhookPost()
{
    // uri = https://webhook.site/f57f777c-1274-41c4-aa97-af9e25782d6c
    var uri = new Uri(Request.GetEncodedUrl());
    // content = {"Id":"4c1d8cc1-1ef6-411f-8078-b1e10139e992"}
    var content = await Request.GetRawBodyAsync();        

    // authorizationHeader = HMAC-SHA256 Signature=4OOstBbS4iOHeWEqnIF2nSOrG+9MKWsBVWCGDgU7CJk=
    var authorizationHeader = Request.Headers.Authorization[0];
    // authorizationTimestamp = Tue, 10 Sep 2024 13:10:32 GMT
    var authorizationTimestamp = Request.Headers["Authorization-Timestamp"][0];
  
    // authorizationHeader = HMAC-SHA256 Signature=4OOstBbS4iOHeWEqnIF2nSOrG+9MKWsBVWCGDgU7CJk=
    // signatureToVerify = 4OOstBbS4iOHeWEqnIF2nSOrG+9MKWsBVWCGDgU7CJk=
    var signatureToVerify = authorizationHeader.Remove(0, "HMAC-SHA256 Signature=".Length);

    // 1. Create a SHA256 hash of the request body content and convert the hash to a Base64 encoded string
    //    content = {"Id":"4c1d8cc1-1ef6-411f-8078-b1e10139e992"}
    //    contentHash = 71MyZ3d9CKN7W9gnIXskBMB2zIWLAmMEM/j2qN3odnU=
    var contentHashAlgorithm = SHA256.Create();
    var contentHash = Convert.ToBase64String(contentHashAlgorithm.ComputeHash(Encoding.UTF8.GetBytes(content)));

    // 2. Construct a signature to be signed in the format:
    //    /f57f777c-1274-41c4-aa97-af9e25782d6c
    //    Tue, 10 Sep 2024 13:10:32 GMT;webhook.site;71MyZ3d9CKN7W9gnIXskBMB2zIWLAmMEM/j2qN3odnU=
    var signatureToSign = $"{uri.PathAndQuery}\n{authorizationTimestamp};{uri.Authority};{contentHash}";

    // 3. Hash the constructed signature by creating an HMAC-SHA256 hash using the same `secretKey` specified in the webhook subscription.
    //    signature = 4OOstBbS4iOHeWEqnIF2nSOrG+9MKWsBVWCGDgU7CJk=
    var hmacSha256 = new HMACSHA256(Encoding.UTF8.GetBytes("my-secret"));
    var signature = Convert.ToBase64String(hmacSha256.ComputeHash(Encoding.UTF8.GetBytes(signatureToSign)));

    // 4. The reconstructed hashed signature should match the value of the signature provided in the request `Authorization` header, if they do not match, the webhook callback should be rejected.
    if (signature != signatureToVerify)
    {
        return Unauthorized();
    }

    // Do work
    return Ok();
}