Implement HMAC Authentication
Learn how to authenticate Apache APISIX clients with request signatures generated from a shared secret.
Applies to:Apache APISIX 3.18.0
Roles:Platform engineerTime:15 minutesImpact:No restart, no traffic change
Goal
A route that authenticates two consumers with HMAC-SHA256 request signatures, forwards their consumer identities upstream, and applies a different request quota to each consumer.
HMAC protects the integrity and authenticity of each signed request. It does not encrypt traffic, so use HTTPS and keep each shared secret out of source control.
Before you begin
- You have an Apache APISIX installation, cURL, jq, and Python 3. Get APISIX installs APISIX in Docker or Kubernetes.
Set the variables this guide uses:
export APISIX_ADMIN_URL="http://127.0.0.1:9180"
export GATEWAY_URL="http://127.0.0.1:9080"
read -rsp "Admin API key: " APISIX_ADMIN_KEY && export APISIX_ADMIN_KEY && echo-
The Admin API accepts your key. Check it with:
curl -s -o /dev/null -w '%{http_code}\n' "${APISIX_ADMIN_URL}/apisix/admin/routes" \ -H "X-API-KEY: ${APISIX_ADMIN_KEY}"Expected:
200. -
The data plane is reachable. Check it with:
curl -sI "${GATEWAY_URL}" | grep -i '^server'Expected: a
Server: APISIX/3.18.0header. -
The consumer names
johndoeandjanedoeand the route IDhmac-auth-routeare unused. Check all three with:for path in consumers/johndoe consumers/janedoe routes/hmac-auth-route; do curl -s -o /dev/null -w "${path}: %{http_code}\n" \ "${APISIX_ADMIN_URL}/apisix/admin/${path}" \ -H "X-API-KEY: ${APISIX_ADMIN_KEY}" doneExpected: each line ends in
404. A200means that identifier already exists; stop and use unique names consistently rather than overwriting it.
Steps
Four steps create the consumers and quotas, attach HMAC credentials, expose the route, and generate current request signatures.
Step 1: Create two consumers with different quotas
Create one consumer allowed one request per 30 seconds and another allowed two.
curl -s "${APISIX_ADMIN_URL}/apisix/admin/consumers" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"username": "johndoe",
"labels": {"custom_id": "john-doe-junior"},
"plugins": {
"limit-count": {"count": 1, "time_window": 30, "rejected_code": 429}
}
}'
curl -s "${APISIX_ADMIN_URL}/apisix/admin/consumers" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"username": "janedoe",
"labels": {"custom_id": "jane-doe-senior"},
"plugins": {
"limit-count": {"count": 2, "time_window": 30, "rejected_code": 429}
}
}'{ "value": { "username": "johndoe", "plugins": { "limit-count": { "count": 1, "time_window": 30, … } } } }
{ "value": { "username": "janedoe", "plugins": { "limit-count": { "count": 2, "time_window": 30, … } } } }What changed: two consumer identities now carry separate local rate limits, but neither can authenticate yet.
Step 2: Create an HMAC credential for each consumer
Attach a unique key ID and shared secret to each consumer.
curl -s "${APISIX_ADMIN_URL}/apisix/admin/consumers/johndoe/credentials" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"id": "cred-john-hmac-auth",
"plugins": {"hmac-auth": {"key_id": "john-key", "secret_key": "john-secret-key"}}
}'
curl -s "${APISIX_ADMIN_URL}/apisix/admin/consumers/janedoe/credentials" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"id": "cred-jane-hmac-auth",
"plugins": {"hmac-auth": {"key_id": "jane-key", "secret_key": "jane-secret-key"}}
}'{ "value": { "id": "cred-john-hmac-auth", "plugins": { "hmac-auth": { "key_id": "john-key", … } } } }
{ "value": { "id": "cred-jane-hmac-auth", "plugins": { "hmac-auth": { "key_id": "jane-key", … } } } }What changed: APISIX can now resolve each key ID to a consumer and verify signatures with that consumer's secret.
Step 3: Create an authenticated route
Expose the upstream at /anything and require hmac-auth on GET requests.
curl -s "${APISIX_ADMIN_URL}/apisix/admin/routes/hmac-auth-route" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"uri": "/anything",
"methods": ["GET"],
"plugins": {"hmac-auth": {}},
"upstream": {
"type": "roundrobin",
"nodes": {"httpbin.org:80": 1}
}
}'{ "value": { "id": "hmac-auth-route", "uri": "/anything", "plugins": { "hmac-auth": {} }, … } }What changed: GET /anything now rejects requests without a valid, timely HMAC signature.
Step 4: Generate current signatures
Create a dependency-free Python script that signs the exact method and path used by this route. It emits shell-safe variables for both consumers and one deliberately invalid signature:
import base64
import hashlib
import hmac
import shlex
from datetime import datetime, timezone
METHOD = "GET"
PATH = "/anything"
DATE = datetime.now(timezone.utc).strftime("%a, %d %b %Y %H:%M:%S GMT")
def authorization(key_id, secret):
signing_string = f"{key_id}\n{METHOD} {PATH}\ndate: {DATE}\n"
digest = hmac.new(secret.encode(), signing_string.encode(), hashlib.sha256).digest()
signature = base64.b64encode(digest).decode()
return (
f'Signature keyId="{key_id}",algorithm="hmac-sha256",'
f'headers="@request-target date",signature="{signature}"'
)
values = {
"HMAC_DATE": DATE,
"JOHN_HMAC_AUTH": authorization("john-key", "john-secret-key"),
"JANE_HMAC_AUTH": authorization("jane-key", "jane-secret-key"),
"INVALID_HMAC_AUTH": authorization("john-key", "wrong-secret-key"),
}
for name, value in values.items():
print(f"export {name}={shlex.quote(value)}")Load freshly generated values into the current shell:
eval "$(python3 hmac-signatures.py)"
HMAC_DATE="${HMAC_DATE:?signature generator did not set HMAC_DATE}"
JOHN_HMAC_AUTH="${JOHN_HMAC_AUTH:?signature generator did not set JOHN_HMAC_AUTH}"
JANE_HMAC_AUTH="${JANE_HMAC_AUTH:?signature generator did not set JANE_HMAC_AUTH}"
INVALID_HMAC_AUTH="${INVALID_HMAC_AUTH:?signature generator did not set INVALID_HMAC_AUTH}"The default clock_skew accepts a signature for 300 seconds. Regenerate these variables if the validation commands run outside that window; do not copy a dated signature from documentation.
Validate
Send a signed request as johndoe and inspect the identity forwarded to the upstream:
curl -s "${GATEWAY_URL}/anything" \
-H "Date: ${HMAC_DATE}" \
-H "Authorization: ${JOHN_HMAC_AUTH}" | \
jq '{username: .headers["X-Consumer-Username"], credential: .headers["X-Credential-Identifier"], custom_id: .headers["X-Consumer-Custom-Id"]}'{
"username": "johndoe",
"credential": "cred-john-hmac-auth",
"custom_id": "john-doe-junior"
}After the current 30-second window expires, regenerate the signatures and send three requests as each consumer:
eval "$(python3 hmac-signatures.py)"
resp=$(seq 3 | xargs -I{} curl -s -o /dev/null -w '%{http_code}\n' "${GATEWAY_URL}/anything" \
-H "Date: ${HMAC_DATE}" -H "Authorization: ${JOHN_HMAC_AUTH}") && \
echo "johndoe — 200: $(echo "$resp" | grep -c 200), 429: $(echo "$resp" | grep -c 429)"
resp=$(seq 3 | xargs -I{} curl -s -o /dev/null -w '%{http_code}\n' "${GATEWAY_URL}/anything" \
-H "Date: ${HMAC_DATE}" -H "Authorization: ${JANE_HMAC_AUTH}") && \
echo "janedoe — 200: $(echo "$resp" | grep -c 200), 429: $(echo "$resp" | grep -c 429)"johndoe — 200: 1, 429: 2
janedoe — 200: 2, 429: 1A signature generated with the wrong secret is rejected:
curl -s -i "${GATEWAY_URL}/anything" \
-H "Date: ${HMAC_DATE}" \
-H "Authorization: ${INVALID_HMAC_AUTH}" | head -1HTTP/1.1 401 UnauthorizedRoll back / Clean up
Remove the route first, then the credentials and consumers it depended on:
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/routes/hmac-auth-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/consumers/johndoe/credentials/cred-john-hmac-auth" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/consumers/janedoe/credentials/cred-jane-hmac-auth" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/consumers/johndoe" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/consumers/janedoe" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"The first command takes this guide's /anything route offline. Each successful deletion returns 200; another route can still match the same path.
Troubleshoot
| Symptom | Cause | Fix |
|---|---|---|
A request returns 401 with client request can't be validated. | The signature does not match the key ID, secret, method, path, or signed header values. | Regenerate the variables and confirm that the script signs GET /anything and the request sends the same Date value. |
A formerly valid request starts returning 401. | The Date has moved outside the plugin's default 300-second clock_skew. | Run eval "$(python3 hmac-signatures.py)" again immediately before retrying. Keep a narrow skew instead of reusing long-lived signatures. |
| More requests succeed than the configured quota. | limit-count uses the local policy by default, so each APISIX instance keeps its own counter. | Use a Redis-backed policy (opens in Plugin Hub docs) when multiple gateway instances must share one counter. |
Related
- Consumers — how APISIX associates plugins and credentials with an API consumer.
- Credentials — how credentials belong to consumers.
hmac-authplugin reference (opens in Plugin Hub docs) — signing rules, allowed algorithms, clock skew, and response behavior.limit-countplugin reference (opens in Plugin Hub docs) — quota keys, counter policies, and rejected responses.- Implement JWT Authentication — authenticate consumers with expiring signed tokens.