Implement JWT Authentication
Learn how to authenticate Apache APISIX clients with short-lived, signed JSON Web Tokens.
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 short-lived HS256 JSON Web Tokens (JWTs), forwards their consumer identities upstream, and applies a different request quota to each consumer.
This example issues tokens locally so the shared secrets never need to be pasted into a third-party website. In production, issue tokens through an identity service, rotate signing secrets, and use HTTPS.
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 IDjwt-auth-routeare unused. Check all three with:for path in consumers/johndoe consumers/janedoe routes/jwt-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 JWT credentials, expose the route, and issue current tokens.
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 a JWT credential for each consumer
Attach one HS256 key and shared secret to each consumer. Use secrets from a secret manager rather than these tutorial values in production.
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-jwt-auth",
"plugins": {
"jwt-auth": {
"key": "john-key",
"secret": "john-hs256-secret-that-is-very-long",
"algorithm": "HS256"
}
}
}'
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-jwt-auth",
"plugins": {
"jwt-auth": {
"key": "jane-key",
"secret": "jane-hs256-secret-that-is-very-long",
"algorithm": "HS256"
}
}
}'{ "value": { "id": "cred-john-jwt-auth", "plugins": { "jwt-auth": { "key": "john-key", "algorithm": "HS256", … } } } }
{ "value": { "id": "cred-jane-jwt-auth", "plugins": { "jwt-auth": { "key": "jane-key", "algorithm": "HS256", … } } } }What changed: APISIX can now resolve each token's key claim to a consumer and verify its HS256 signature.
Step 3: Create an authenticated route
Expose the upstream at /anything, require jwt-auth, and require every token to contain a valid expiration time.
curl -s "${APISIX_ADMIN_URL}/apisix/admin/routes/jwt-auth-route" -X PUT \
-H "X-API-KEY: ${APISIX_ADMIN_KEY}" \
-H "Content-Type: application/json" \
-d '{
"uri": "/anything",
"plugins": {"jwt-auth": {"claims_to_verify": ["exp"]}},
"upstream": {
"type": "roundrobin",
"nodes": {"httpbin.org:80": 1}
}
}'{ "value": { "id": "jwt-auth-route", "uri": "/anything", "plugins": { "jwt-auth": { "claims_to_verify": ["exp"] } }, … } }What changed: /anything now rejects requests without a correctly signed, unexpired token that contains an exp claim.
Step 4: Issue current JWTs
Create a dependency-free Python script that issues one HS256 token per consumer. Each token is valid for five minutes:
import base64
import hashlib
import hmac
import json
import shlex
import time
def encode(value):
raw = json.dumps(value, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def issue(key, secret):
now = int(time.time())
header = encode({"alg": "HS256", "typ": "JWT"})
payload = encode({"key": key, "iat": now, "exp": now + 300})
signing_input = f"{header}.{payload}"
signature = hmac.new(secret.encode(), signing_input.encode(), hashlib.sha256).digest()
encoded_signature = base64.urlsafe_b64encode(signature).rstrip(b"=").decode()
return f"{signing_input}.{encoded_signature}"
tokens = {
"JOHN_JWT": issue("john-key", "john-hs256-secret-that-is-very-long"),
"JANE_JWT": issue("jane-key", "jane-hs256-secret-that-is-very-long"),
}
for name, token in tokens.items():
print(f"export {name}={shlex.quote(token)}")Load freshly issued tokens into the current shell:
eval "$(python3 issue-jwts.py)"
JOHN_JWT="${JOHN_JWT:?token issuer did not set JOHN_JWT}"
JANE_JWT="${JANE_JWT:?token issuer did not set JANE_JWT}"Do not paste production signing secrets into online token tools or commit them to source control. Regenerate these tutorial tokens if validation takes longer than five minutes.
Validate
Send a request as johndoe and inspect the identity forwarded to the upstream:
curl -s "${GATEWAY_URL}/anything" -H "Authorization: ${JOHN_JWT}" | \
jq '{username: .headers["X-Consumer-Username"], credential: .headers["X-Credential-Identifier"], custom_id: .headers["X-Consumer-Custom-Id"]}'{
"username": "johndoe",
"credential": "cred-john-jwt-auth",
"custom_id": "john-doe-junior"
}After the current 30-second window expires, issue new tokens and send three requests as each consumer:
eval "$(python3 issue-jwts.py)"
resp=$(seq 3 | xargs -I{} curl -s -o /dev/null -w '%{http_code}\n' "${GATEWAY_URL}/anything" \
-H "Authorization: ${JOHN_JWT}") && \
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 "Authorization: ${JANE_JWT}") && \
echo "janedoe — 200: $(echo "$resp" | grep -c 200), 429: $(echo "$resp" | grep -c 429)"johndoe — 200: 1, 429: 2
janedoe — 200: 2, 429: 1An invalid token is rejected:
curl -s -i "${GATEWAY_URL}/anything" -H 'Authorization: not-a-jwt' | 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/jwt-auth-route" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/consumers/johndoe/credentials/cred-john-jwt-auth" -H "X-API-KEY: ${APISIX_ADMIN_KEY}"
curl -s -X DELETE "${APISIX_ADMIN_URL}/apisix/admin/consumers/janedoe/credentials/cred-jane-jwt-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 JWT token invalid or failed to verify jwt. | The token is malformed, its signature does not match the credential secret, or its alg does not exactly match the configured algorithm. | Run eval "$(python3 issue-jwts.py)" again and confirm the credential uses HS256 with the same secret as the script. |
A previously valid token returns 401. | Its five-minute exp window has elapsed, or the issuer and gateway clocks differ. | Synchronize both clocks and issue a new token. Increase the credential's lifetime_grace_period only enough to accommodate expected clock skew. |
The response says the exp claim is missing. | This route explicitly lists exp in claims_to_verify, so APISIX requires it. | Issue the token with the script in step 4 or include a future UNIX timestamp in exp. |
| 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.
jwt-authplugin reference (opens in Plugin Hub docs) — token locations, algorithms, claims, and response behavior.limit-countplugin reference (opens in Plugin Hub docs) — quota keys, counter policies, and rejected responses.- Implement HMAC Authentication — sign each request directly with a shared secret.