Blog Field Notes Diagnosing 401 Invalid Signature on a Bill Payment Webhook Azure Function
Debug #azure-functions#rsa#signature-verification#python#terraform

Diagnosing 401 Invalid Signature on a Bill Payment Webhook Azure Function

Traced a persistent 401 on an Azure Function webhook verifying RSA-signed payment notifications to three compounding failures: wrong body in the test request, a hardcoded test organisation code in Terraform, and a key mode mismatch between the deployed function and the Postman collection.

· Gideon Warui
ON THIS PAGE

The integration team reported that every bill payment notification from the payment provider was being rejected with a 401. The Azure Function at <function-app> — which acts as the receiving webhook for the bill payment system — was returning this response to every inbound request:

{
    "transactionID": "91c25296-5ba2-4f8d-ac04-d8dc889353",
    "statusCode": 1,
    "statusMessage": "Invalid signature - unauthorized request"
}

The function does one thing: receive a JSON payload, verify the RSA signature in the signature header using the provider’s public key, and respond with a statusCode: 0 acknowledgement. It was not doing the last part.


Environment

ComponentDetail
Function runtimeAzure Functions v4, Python 3.10
Signature algorithmSHA256withRSA (PKCS1v15)
Key storageAzure Key Vault via managed identity
InfrastructureTerraform, West Europe
Function app<function-app>, <resource-group>

What the 401 actually told me

The first thing a 401 from this function rules out is infrastructure failure. Looking at function_app.py, the response path for a 401 is:

logging.error("Signature verification failed - unauthorized request")
return error_response(transaction_id, "Invalid signature - unauthorized request", 401)

To reach that line, the function must have already:

  • Loaded a non-placeholder public key (otherwise 503)
  • Received a non-empty signature header (otherwise 400)
  • Successfully base64-decoded the signature (otherwise 400 with invalid_signature_format)
  • Attempted public_key.verify() and had it raise InvalidSignature

So the function was running, the key was loaded, the request was well-formed. The cryptographic verification was failing. That narrowed it to one of two things: wrong key, or wrong bytes being verified.


First look: the broken local venv

Before going further into the 401, I checked the local environment and found the venv was broken. venv/pyvenv.cfg showed version = 3.10.12 but venv/bin/python3 symlinked to /usr/bin/python3 which was Python 3.12. The packages were installed under lib/python3.10/site-packages/ but the interpreter was Python 3.12, which ignored them entirely.

$ /path/to/project/venv/bin/python3 -c "import sys; print(sys.path)"
['', '/usr/lib/python312.zip', '/usr/lib/python3.12', '/usr/lib/python3.12/lib-dynload']

No site-packages. Every import failed. Python 3.10 had been removed from the machine at some point and the venv’s symlink target changed under it. This only affects local development — Azure manages its own venv from requirements.txt on deployment — but it explained why local test runs were failing.

The fix is to rebuild the venv:

rm -rf venv
python3 -m venv venv
venv/bin/pip install -r requirements.txt

Tracing the 401 to its source

The integration team’s test was being routed through a Power Automate Logic Apps workflow (prod-229.westeurope.logic.azure.com) before reaching the function. The full request that arrived at the function included the original signature header and a JSON body with Content-Length: 522.

My first suspicion was body transformation. RSA signature verification is byte-exact: the bytes hashed during signing must exactly match the bytes hashed during verification. A single added space or reordered field breaks it. If Logic Apps was parsing the JSON body and re-serializing it before forwarding to the function, the bytes would change and the signature would fail regardless of the key.

I checked what body size 522 bytes corresponded to:

body = {"transactionReference": "...", ...}  # 14 fields

json.dumps(body, separators=(',',':')):  437 bytes   # compact
json.dumps(body, indent=2):              494 bytes
json.dumps(body, indent=4):              522 bytes   # <- matches Content-Length

The body arriving at the function was 4-space indented. If the provider signed compact JSON, that was the mismatch.

But before concluding that, I needed to confirm the key was correct.


Key inventory

The repo had three public keys:

FileKey (first 8 chars of modulus)
local.settings.jsonCLIENT_PUBLIC_KEYp9omBUp/
tests/keys/test_public_key.pemp9omBUp/ (same)
<client>_publickey.pem7Sv51Yga
Reference-Documents/<client>_test_publickey.pem7Sv51Yga (same)

Two distinct key pairs. I tested the failing signature from the team’s request (BFqKUfu/...) against both across every body format (compact, 2-space, 4-space, sorted keys, exact as pasted):

local.settings CLIENT_PUBLIC_KEY  | compact         | fail
local.settings CLIENT_PUBLIC_KEY  | 4-space indent  | fail
<client>_publickey.pem            | compact         | fail
<client>_publickey.pem            | 4-space indent  | fail
... (all 16 combinations)         |                 | fail

Every combination failed. The signature BFqKUfu/... was generated by a private key that had no corresponding public key anywhere in the repo.

I also checked the UAT function’s health endpoint:

{"status": "healthy", "keyMode": "<client>"}

keyMode: "<client>" means the function loads from CLIENT_PUBLIC_KEY_<CLIENT>, which comes from the Key Vault secret public-key-<client>-pem. The Postman UAT collection note said these test samples “pass while key mode is test” — but the function was running in <client> mode. That was a separate misconfiguration: the Postman test signatures were signed against local.settings.json’s key (p9omBUp/), but the function was loading <client>_publickey.pem (7Sv51Yga). Every Postman test would fail against UAT as deployed.


Finding the right key and the right body

The user confirmed that <client>_publickey.pem was the public key the provider sent for their system. So the Key Vault had the right key. The signature failure had to be the body.

To confirm, I raw-decrypted the signature using the provider’s public key to extract the hash embedded in it, then compared it against SHA256 of the bodies I had:

Hash embedded in signature (via provider public key): c4d4347eb2b7f6ab76ce36592856201cfea3f86f48db9ad2b4ca581ad593d7a9
SHA256 of body (exact as pasted):                     814ea7a1ad17109f0d411fc9ea95953ce90cb874c493f80680249bd8d8e8bec4
SHA256 of body (compact):                             da5ce6bc5391bb5b8f85d5a2ee7406bdecba4a4ba534a3eae2c9ee3e1f580901

Neither matched. The signature embedded hash c4d4347e... matched no body we had. The transactionReference value in the body being tested (TEXEgg22WCY9T) was wrong — a transcription error. The correct value from the original curl command was TT26022WCYJD9T.

With the correct body:

curl --location 'https://<function-app>.azurewebsites.net/api/billpaymentnotification?code=...' \
  --header 'Content-Type: application/json' \
  --header 'signature: BGfMaYCk...' \
  --data '{"transactionReference":"TT26022WCYJD9T",...}'

Local verification:

Provider key (<client>_publickey.pem) | compact (426 bytes) | PASS

SHA256 of this body: c4d4347e... — matches the hash embedded in the signature exactly. The key was always right. The body in earlier tests had the wrong transactionReference.


The organisation code block

After the signature check passes, the function runs a secondary check:

if EXPECTED_ORGANIZATION_CODE:
    organization_code = data.get("organizationShortCode")
    if organization_code and organization_code != EXPECTED_ORGANIZATION_CODE:
        return error_response(..., "Invalid organization short code", 422)

Both infra/environments/prod/terraform.tfvars and infra/environments/uat/terraform.tfvars had:

organization_code = "777777"

That was the test org code from development. The provider’s actual payload sends organizationShortCode: "<org-code>". With a valid signature, the function would reach this check and return 422 before ever acknowledging the notification.

The org code check is redundant — if the signature verifies, the payload is cryptographically proven to have come from the provider. Checking a field inside a verified payload is security theatre. I removed it by setting the variable to an empty string in both tfvars files, then applied directly via the Azure CLI since prod infrastructure was not yet deployed:

az account set --subscription "<subscription-id>"

az functionapp config appsettings set \
  --name <function-app> \
  --resource-group <resource-group> \
  --settings ORGANIZATION_CODE=""

Result:

{"name": "ORGANIZATION_CODE", "slotSetting": false, "value": null}

End-to-end test

curl -s -X POST \
  "https://<function-app>.azurewebsites.net/api/billpaymentnotification?code=..." \
  -H "Content-Type: application/json" \
  -H 'signature: BGfMaYCk...' \
  -d '{"transactionReference":"TT26022WCYJD9T","requestId":"91c25296-5ba2-4f8d-ac04-d8dc889353","channelCode":"101","timestamp":"20260211204459","currency":"KES","customerReference":"73119","customerName":"KDS829C","balance":"10000.00","customerMobileNumber":"0724567890","narration":"Bill Payments","creditAccountIdentifier":"1149212292","organizationShortCode":"<org-code>","transactionAmount":"10000.00","tillNumber":"Not Applicable"}'
{
    "transactionID": "91c25296-5ba2-4f8d-ac04-d8dc889353",
    "statusCode": 0,
    "statusMessage": "Notification received"
}
  1. Signature verified, notification accepted.

What was actually wrong

Three independent issues were stacked:

1. Wrong body in the test request. The transactionReference value in earlier test attempts did not match what the provider had actually signed. One character difference produces a completely different SHA256 hash. The signature, key, and function were all correct — the test body was wrong.

2. Hardcoded test organisation code in Terraform. organization_code = "777777" was carried forward from development into both UAT and prod tfvars. The provider sends their real org code. This would have produced a 422 on every real notification that got past signature verification. The check itself is unnecessary — a valid RSA signature already proves the request came from the provider.

3. Logic Apps body transformation (open). The team’s test routes through a Power Automate Logic Apps workflow before reaching the function. If Logic Apps re-serializes the JSON body when constructing the outbound HTTP request to the function (which it does by default when using @triggerBody() in the body field), the byte sequence changes and signature verification fails. This was not the cause of the specific 401 fixed here, but it will cause failures for real notifications routed through Logic Apps unless the workflow is configured to forward the raw body bytes unchanged.


What the function needs from Logic Apps

The provider signs the exact bytes of the JSON body they send. The function verifies against the exact bytes it receives. Any transformation in between breaks the chain.

In the Logic Apps HTTP action that calls the function, the body field must reference the raw trigger body, not a re-parsed object:

Wrong:  @triggerBody()                    <- Logic Apps re-serializes as JSON
Wrong:  @{triggerBody()}                  <- same problem
Correct: use the raw body expression or pass through the original request body bytes

The function has no control over what arrives in req.get_body(). The byte integrity has to be preserved upstream.

#azure-functions#rsa#signature-verification#python#terraform