Docs
API7 GatewaySecurity and complianceAPI SecurityConfigure JWT Authentication

Configure JWT Authentication

Issue a short-lived HS256 token, validate it at API7 Gateway, and verify the resolved consumer identity.

Applies to:API7 Gateway 3.10.x

Roles:Platform engineer

Goal

A route that rejects requests without a JWT and accepts a freshly generated HS256 token for a named consumer.

The jwt-auth (opens in Plugin Hub docs) plugin verifies a token signature against a consumer credential. This example uses the credential key as the JWT claim and sets hide_credentials: true so the bearer token is removed before proxying.

Before you begin

Set the variables before running any checks:

export API7_ADMIN_URL="https://localhost:7443"
export API7_GATEWAY_GROUP_ID="default"   # only the group the quickstart creates; otherwise copy the gateway group's UUID from Gateway Groups in the Dashboard
read -rp "Gateway group name for ADC (if used): " API7_GATEWAY_GROUP_NAME && export API7_GATEWAY_GROUP_NAME
export GATEWAY_URL="http://127.0.0.1:9080"
read -rsp "Dashboard token: " API7_ADMIN_KEY && export API7_ADMIN_KEY && echo
  • An API7 Gateway control plane is reachable and your Dashboard token is valid:

    curl -ks -o /dev/null -w '%{http_code}\n' "${API7_ADMIN_URL}/api/gateway_groups" \
      -H "X-API-KEY: ${API7_ADMIN_KEY}"

    Expected: 200.

  • A gateway instance in the selected group reports Healthy:

    curl -ks "${API7_ADMIN_URL}/api/gateway_groups/${API7_GATEWAY_GROUP_ID}/instances" \
      -H "X-API-KEY: ${API7_ADMIN_KEY}" | grep -o '"status": *"Healthy"' | wc -l

    Expected: 1 or more.

  • Required local tools are available:

    jq --version
    openssl version

    Expected: jq and OpenSSL version strings.

  • For the ADC path, ADC is installed and supports label selectors:

    adc --version
    adc sync --help | grep -- '--label-selector'

    Expected: a version string and a help entry for --label-selector. The option is documented in ADC v0.30.3; use a version whose help lists it. ADC looks up --gateway-group by gateway group display name, so API7_GATEWAY_GROUP_NAME is distinct from the UUID used by the compatibility Admin API. Enter the name of the same group identified by API7_GATEWAY_GROUP_ID. If similar group names make the lookup ambiguous, stop and confirm which group your installed ADC resolves before continuing.

    ADC writes diff.yaml in the current directory. Before saving or running this guide's ADC examples, confirm that none of its local paths already exists:

    for file in jwt-auth-adc.yaml jwt-auth-cleanup.yaml diff.yaml; do test ! -e "${file}" || { echo "Stop: ${file} already exists"; exit 1; }; done

    Expected: no output. This makes the local cleanup safe; otherwise use a new empty working directory.

This guide creates only these gateway resources: jwt-auth-httpbin-service, jwt-auth-route, jwt-auth-consumer, and jwt-auth-consumer-cred. Check that the first three names are unused:

curl -ks -w '\nHTTP status: %{http_code}\n' "${API7_ADMIN_URL}/apisix/admin/services/jwt-auth-httpbin-service?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
curl -ks -w '\nHTTP status: %{http_code}\n' "${API7_ADMIN_URL}/apisix/admin/routes/jwt-auth-route?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
curl -ks -w '\nHTTP status: %{http_code}\n' "${API7_ADMIN_URL}/apisix/admin/consumers/jwt-auth-consumer?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"

For the Admin API path, expected: each response reports that the requested resource is absent. If a response has HTTP status 200 and a populated matching value.id or value.username, that ID is already in use; stop or change every occurrence before continuing. Do not treat another error as proof of absence: correct the token, gateway group, or request until the result is unambiguous. The PUT operations below update a matching ID.

ADC can encounter an object with the same name under a different backend ID, so this check alone does not prove ADC ownership. On the ADC path, continue only if adc diff shows creates for exactly the four example objects and no update, replacement, or deletion of an existing object.

Steps

Choose either the Admin API path in steps 1–3 or the ADC path in step 4. Do not apply both.

Step 1: Create a service with an upstream

curl -ks "${API7_ADMIN_URL}/apisix/admin/services/jwt-auth-httpbin-service?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "jwt-auth-httpbin-service",
    "labels": {"guide": "jwt-auth"},
    "upstream": {
      "type": "roundrobin",
      "scheme": "http",
      "nodes": [{"host": "httpbin.org", "port": 80, "weight": 100}]
    }
  }'
{ "value": { "id": "jwt-auth-httpbin-service", "name": "jwt-auth-httpbin-service", … } }

What changed: the example service now sends traffic to httpbin.org; no route points to it yet.

Step 2: Create a route and enable jwt-auth

curl -ks "${API7_ADMIN_URL}/apisix/admin/routes/jwt-auth-route?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "jwt-auth-route",
    "labels": {"guide": "jwt-auth"},
    "paths": ["/anything/jwt-auth"],
    "methods": ["GET"],
    "service_id": "jwt-auth-httpbin-service",
    "plugins": {"jwt-auth": {"hide_credentials": true}}
  }'
{ "value": { "id": "jwt-auth-route", "name": "jwt-auth-route", … } }

What changed: /anything/jwt-auth now requires a valid jwt-auth credential. With no credential yet, every request is rejected.

Step 3: Create a consumer and credential

curl -ks "${API7_ADMIN_URL}/apisix/admin/consumers/jwt-auth-consumer?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"username": "jwt-auth-consumer", "labels": {"guide": "jwt-auth"}}'

curl -ks "${API7_ADMIN_URL}/apisix/admin/consumers/jwt-auth-consumer/credentials/jwt-auth-consumer-cred?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -X PUT \
  -H "X-API-KEY: ${API7_ADMIN_KEY}" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "jwt-auth-consumer-cred",
    "plugins": {"jwt-auth": {"key": "user-1-identity", "secret": "my-shared-secret"}}
  }'
{ "value": { "username": "jwt-auth-consumer", … } }
{ "value": { "id": "jwt-auth-consumer-cred", "name": "jwt-auth-consumer-cred", … } }

What changed: tokens signed with my-shared-secret and carrying key=user-1-identity resolve to jwt-auth-consumer.

Step 4: Apply the same configuration with ADC

Skip this alternative if you completed steps 1–3. Save the complete desired state:

jwt-auth-adc.yaml
services:
  - name: jwt-auth-httpbin-service
    labels:
      guide: jwt-auth
    upstream:
      name: default
      scheme: http
      nodes:
        - host: httpbin.org
          port: 80
          weight: 100
    routes:
      - name: jwt-auth-route
        labels:
          guide: jwt-auth
        uris:
          - /anything/jwt-auth
        methods:
          - GET
        plugins:
          jwt-auth:
            hide_credentials: true
consumers:
  - username: jwt-auth-consumer
    labels:
      guide: jwt-auth
    credentials:
      - name: jwt-auth-consumer-cred
        type: jwt-auth
        config:
          key: user-1-identity
          secret: my-shared-secret

Lint locally, ask the backend to validate the file, and inspect the diff:

adc lint -f jwt-auth-adc.yaml
adc validate -f jwt-auth-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=jwt-auth
adc diff -f jwt-auth-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=jwt-auth

Expected: lint and validation succeed, and the diff contains only this example's service, nested route, consumer, and credential. The selector scopes reconciliation to top-level resources labeled guide=jwt-auth; the route remains part of its selected service.

After reviewing the diff, apply it:

adc sync -f jwt-auth-adc.yaml --backend api7ee --server "${API7_ADMIN_URL}" --token "${API7_ADMIN_KEY}" --gateway-group "${API7_GATEWAY_GROUP_NAME}" --tls-skip-verify --label-selector guide=jwt-auth

Expected: ADC applies only the changes shown for the guide=jwt-auth partition.

What changed: ADC reconciled the reviewed desired state within the guide=jwt-auth partition.

Validate

A request without a token should be rejected:

curl -is "${GATEWAY_URL}/anything/jwt-auth" | head -1
HTTP/1.1 401 Unauthorized

Generate a token that expires five minutes from now. The key claim must match the credential in step 3:

header="$(printf %s '{"alg":"HS256","typ":"JWT"}' | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
expires_at="$(( $(date +%s) + 300 ))"
payload="$(printf '{"key":"user-1-identity","exp":%s}' "${expires_at}" | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
signature="$(printf %s "${header}.${payload}" | openssl dgst -binary -sha256 -hmac 'my-shared-secret' | openssl base64 -A | tr '+/' '-_' | tr -d '=')"
JWT_TOKEN="${header}.${payload}.${signature}" && export JWT_TOKEN

Send it and inspect the fields that prove consumer resolution and token removal:

curl -s "${GATEWAY_URL}/anything/jwt-auth" -H "Authorization: Bearer ${JWT_TOKEN}" | jq '{headers: {"X-Consumer-Username": .headers["X-Consumer-Username"], "X-Credential-Identifier": .headers["X-Credential-Identifier"]}, authorization_forwarded: (.headers.Authorization != null)}'
{
  "headers": {
    "X-Consumer-Username": "jwt-auth-consumer",
    "X-Credential-Identifier": "jwt-auth-consumer-cred"
  },
  "authorization_forwarded": false
}

Roll back / Clean up

Use the cleanup path that matches how you created the resources. Both paths are limited to this guide's IDs or label partition.

Delete the four fixed IDs in reverse dependency order:

curl -ks -o /dev/null -w '%{http_code}\n' -X DELETE "${API7_ADMIN_URL}/apisix/admin/consumers/jwt-auth-consumer/credentials/jwt-auth-consumer-cred?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
curl -ks -o /dev/null -w '%{http_code}\n' -X DELETE "${API7_ADMIN_URL}/apisix/admin/consumers/jwt-auth-consumer?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
curl -ks -o /dev/null -w '%{http_code}\n' -X DELETE "${API7_ADMIN_URL}/apisix/admin/routes/jwt-auth-route?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"
curl -ks -o /dev/null -w '%{http_code}\n' -X DELETE "${API7_ADMIN_URL}/apisix/admin/services/jwt-auth-httpbin-service?gateway_group_id=${API7_GATEWAY_GROUP_ID}" -H "X-API-KEY: ${API7_ADMIN_KEY}"

Expected: each command prints 200.

After cleanup, the example route no longer handles /anything/jwt-auth. Another route can still match that path, so the data-plane response is not guaranteed to be 404.

Remove only the local example files you saved:

rm -f jwt-auth-adc.yaml jwt-auth-cleanup.yaml diff.yaml

Expected: no output.

Troubleshoot

SymptomCauseFix
401 with {"message":"failed to verify jwt"}.The token is expired, malformed, signed with another secret, or carries a key that does not match the credential.Generate a fresh token and compare the key and secret with step 3.
The shell prints an empty token component.OpenSSL or date failed, or a command was copied incompletely.Run each assignment separately and confirm header, payload, and signature are nonempty.
ADC proposes unrelated changes.The selector is missing or another resource uses the same guide label.Stop before sync; use the exact selector and reserve guide=jwt-auth for this example.