Docs

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.0 header.

  • The consumer names johndoe and janedoe and the route ID jwt-auth-route are 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}"
    done

    Expected: each line ends in 404. A 200 means 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:

issue-jwts.py
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: 1

An invalid token is rejected:

curl -s -i "${GATEWAY_URL}/anything" -H 'Authorization: not-a-jwt' | head -1
HTTP/1.1 401 Unauthorized

Roll 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

SymptomCauseFix
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.