Skip to content
Using Custom JWT Claims for Authorization in Istio Gateway

Using Custom JWT Claims for Authorization in Istio Gateway

March 28, 2026

The Problem: Beyond Role-Based Access Control

Role-based access control (RBAC) works fine when your authorization model is simple: “admins can do X, users can do Y”. But real-world authorization is messier:

  • Multi-tenant systems need to ensure users can only access their tenant’s data
  • APIs need to enforce audience claims (this token is only valid for this service)
  • Fine-grained permissions (read, write, delete) change per user
  • Custom business logic (users in department X can only access resources created on Mondays)

Istio’s AuthorizationPolicy can handle role-based rules, but it requires hardcoding roles into policies. Custom JWT claims let your authorization decisions follow the token — no policy changes needed when permissions change.


How Custom Claims Flow Through Istio

When a client makes a request with a JWT token:

    sequenceDiagram
    participant Client
    participant Gateway as Istio Gateway<br/>(Envoy)
    participant Auth as RequestAuthentication
    participant AuthZ as AuthorizationPolicy
    participant Upstream as Backend<br/>Service

    Client->>Gateway: GET /api/data<br/>Authorization: Bearer eyJhbG...

    Gateway->>Auth: Extract claims<br/>from JWT

    Auth->>Auth: Validate signature<br/>Check expiry

    alt JWT Valid
        Auth->>Gateway: Inject claims as headers<br/>x-jwt-claim-sub: user123<br/>x-jwt-claim-tenant: acme<br/>x-jwt-claim-roles: editor,viewer

        Gateway->>AuthZ: Check AuthorizationPolicy<br/>using claim values

        alt Authorization Check Passes
            AuthZ->>Upstream: Forward request<br/>+ claims headers
            Upstream->>Client: 200 OK
        else Authorization Check Fails
            AuthZ->>Client: 403 Forbidden
        end
    else JWT Invalid
        Auth->>Client: 401 Unauthorized
    end
  

The key insight: JWT claims become request attributes that AuthorizationPolicy can make decisions on. You don’t need to hardcode permissions — they come from the token.


Part 1: JWT Validation with RequestAuthentication

Before you can use claims for authorization, you need to extract and validate them from the JWT. This is the job of RequestAuthentication.

Setup: Configure the OIDC Provider

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: jwt-auth
  namespace: istio-system
spec:
  selector:
    matchLabels:
      istio: ingressgateway
  jwtRules:
  - issuer: "https://accounts.example.com"
    jwksUri: "https://accounts.example.com/.well-known/jwks.json"
    audiences: "api.example.com"
    # Extract these specific claims and expose them as headers
    outputPayloadToHeader: "x-jwt-payload"
    forwardOriginalToken: true
    # Rules for different authentication flows
    rules:
    - methods: ["*"]
      principal: "*"

What each field does:

FieldPurpose
issuerThe JWT issuer URL (must match the iss claim in tokens)
jwksUriJWKS endpoint to fetch public keys for signature verification
audiencesExpected audience claim (aud). Tokens must contain this or they’re rejected
outputPayloadToHeaderIstio will decode and base64-encode the JWT payload to this header
forwardOriginalTokenKeep the original Authorization header (useful for upstream services)

Testing JWT Validation

Generate a test JWT with custom claims:

# Create a test token (in practice, your OIDC provider does this)
TOKEN=$(curl -s -X POST https://accounts.example.com/token \
  -d 'client_id=test&client_secret=secret&grant_type=client_credentials&audience=api.example.com' \
  | jq -r '.access_token')

# Decode the token to see the claims
echo $TOKEN | jq -R 'split(".") | .[1] | @base64d | fromjson'
# Output:
# {
#   "iss": "https://accounts.example.com",
#   "sub": "user123",
#   "aud": "api.example.com",
#   "tenant_id": "acme",
#   "roles": ["editor", "viewer"],
#   "permissions": ["read", "write"],
#   "department": "engineering",
#   "exp": 1711632000,
#   "iat": 1711545600
# }

# Make a request through the gateway
curl -i -H "Authorization: Bearer $TOKEN" \
  https://api.example.com/api/data

If the JWT is valid, Istio injects the claims into the request as headers (via outputPayloadToHeader and as individual claim headers).


Part 2: Authorization with Custom Claims

Once claims are extracted, AuthorizationPolicy can use them to make authorization decisions.

Matching JWT Claims Directly

Istio supports matching JWT claims directly in AuthorizationPolicy. Once RequestAuthentication validates the token, the claims become available for authorization decisions via request.auth.claims[claim_name]:

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: claim-based-authz
  namespace: istio-system
spec:
  selector:
    matchLabels:
      istio: ingressgateway
  action: ALLOW
  rules:
  # Allow users with "admin" role to access /admin paths
  - from:
    - source:
        principals: ["*"]
    to:
    - operation:
        paths: ["/admin/*"]
    when:
    - key: request.auth.claims[roles]
      values: ["admin"]

  # Allow users to access their tenant's data
  - from:
    - source:
        principals: ["*"]
    to:
    - operation:
        paths: ["/api/tenants/*"]
    when:
    - key: request.auth.claims[tenant_id]
      values: ["acme", "globex", "initech"]  # Whitelisted tenants

  # Allow read-only access with "viewer" role
  - from:
    - source:
        principals: ["*"]
    to:
    - operation:
        methods: ["GET", "HEAD"]
        paths: ["/*"]
    when:
    - key: request.auth.claims[roles]
      values: ["viewer"]

Important: request.auth.claims[claim_name] works only if the JWT was validated via RequestAuthentication. The claim values in the JWT must match the values list in the policy.


Example: Multi-Tenant SaaS Gateway

Here’s a real-world example for a SaaS platform:

JWT Token Example

{
  "iss": "https://auth.example.com",
  "sub": "user@acme.com",
  "aud": "api.example.com",
  "tenant_id": "acme",
  "org_id": "org_acme_123",
  "roles": ["editor", "viewer"],
  "permissions": ["read", "write", "delete"],
  "department": "engineering",
  "created_at": 1711545600,
  "exp": 1711632000
}

RequestAuthentication

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: saas-jwt
  namespace: istio-system
spec:
  selector:
    matchLabels:
      istio: ingressgateway
  jwtRules:
  - issuer: "https://auth.example.com"
    jwksUri: "https://auth.example.com/.well-known/jwks.json"
    audiences: "api.example.com"
    outputPayloadToHeader: "x-jwt-payload"
    forwardOriginalToken: true

AuthorizationPolicy

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: saas-authz
  namespace: istio-system
spec:
  selector:
    matchLabels:
      istio: ingressgateway
  action: ALLOW
  rules:
  # Public endpoints (no auth required)
  - to:
    - operation:
        paths:
        - "/health"
        - "/readyz"
        - "/signup"
        - "/login"

  # Tenant-specific endpoints: must have matching tenant_id
  - from:
    - source:
        principals: ["*"]
    to:
    - operation:
        paths: ["/api/tenants/*/"]
    when:
    - key: request.auth.claims[tenant_id]
      values: ["acme", "globex", "initech"]  # Whitelisted tenants

  # Admin panel: only for users with "admin" role
  - from:
    - source:
        principals: ["*"]
    to:
    - operation:
        paths: ["/admin/*"]
    when:
    - key: request.auth.claims[roles]
      values: ["admin"]

  # Engineering department only: /internal endpoints
  - from:
    - source:
        principals: ["*"]
    to:
    - operation:
        paths: ["/internal/*"]
    when:
    - key: request.auth.claims[department]
      values: ["engineering"]

  # Write operations require "editor" or "admin" role
  - from:
    - source:
        principals: ["*"]
    to:
    - operation:
        methods: ["POST", "PUT", "PATCH", "DELETE"]
        paths: ["/api/*"]
    when:
    - key: request.auth.claims[roles]
      values: ["editor", "admin"]

  # Read-only access with "viewer" role (anyone with this role can read)
  - from:
    - source:
        principals: ["*"]
    to:
    - operation:
        methods: ["GET", "HEAD"]
        paths: ["/api/*"]
    when:
    - key: request.auth.claims[roles]
      values: ["viewer", "editor", "admin"]

Debugging Custom Claims Authorization

1. Verify JWT Validation

Check if the RequestAuthentication is validating tokens:

# Watch Envoy logs on the gateway
kubectl logs deploy/istio-ingressgateway -n istio-system -f | grep -i jwt

# Expected: "JWT verification succeeded" or "JWT validation failed"

2. Extract and Inspect the Payload Header

The x-jwt-payload header contains the base64-encoded JWT payload:

# Make a request and extract the payload header
PAYLOAD=$(curl -s -H "Authorization: Bearer $TOKEN" \
  https://api.example.com/api/data \
  -w "\n%{http_code}\n" | grep "^x-jwt-payload" | cut -d' ' -f2)

# Decode it
echo $PAYLOAD | base64 -d | jq .

3. Check AuthorizationPolicy Logs

# Set debug logging on the gateway
istioctl proxy-config log deploy/istio-ingressgateway \
  -n istio-system --level rbac:debug

# Watch logs for policy decisions
kubectl logs deploy/istio-ingressgateway -n istio-system -f | grep -i authz

4. Use istioctl analyze to Check Config

# Validate your AuthorizationPolicy syntax
istioctl analyze

# Output will flag any misconfigurations
# e.g., "AUTHZ_POLICY_NOT_ENFORCED: AuthorizationPolicy not found for workload"

5. Test with curl

# Without token
curl -i https://api.example.com/api/data
# Expected: 401 Unauthorized (RequestAuthentication required)

# With invalid token
curl -i -H "Authorization: Bearer invalid" \
  https://api.example.com/api/data
# Expected: 401 Unauthorized (JWT validation failed)

# With valid token but insufficient claims
TOKEN=$(create_jwt_with_claims '{"roles": ["viewer"]}')
curl -i -H "Authorization: Bearer $TOKEN" \
  -X POST https://api.example.com/api/data
# Expected: 403 Forbidden (AuthorizationPolicy denies)

# With valid token and correct claims
TOKEN=$(create_jwt_with_claims '{"roles": ["editor"]}')
curl -i -H "Authorization: Bearer $TOKEN" \
  -X POST https://api.example.com/api/data
# Expected: 200 OK (allowed)

Advanced: Claim Arrays and String Matching

JWT claims can be arrays (like roles: ["editor", "viewer"]). AuthorizationPolicy checks if any value in the claim array matches any value in the policy’s values list:

# Token has:
# "roles": ["viewer", "editor"]

# Policy with:
when:
- key: request.auth.claims[roles]
  values: ["admin"]
# Result: DENY (no match)

# Policy with:
when:
- key: request.auth.claims[roles]
  values: ["editor"]  # "editor" is in the token's roles array
# Result: ALLOW

For string claims (like tenant_id: "acme"), the match is exact:

# Token has:
# "tenant_id": "acme"

# Policy with:
when:
- key: request.auth.claims[tenant_id]
  values: ["acme", "globex"]
# Result: ALLOW (acme is in the list)

# Policy with:
when:
- key: request.auth.claims[tenant_id]
  values: ["other"]
# Result: DENY

Best Practices

1. Always Validate Audience Claims

Ensure tokens are intended for your service:

jwtRules:
- issuer: "https://auth.example.com"
  jwksUri: "..."
  audiences: "api.example.com"  # ← Critical

A token issued for service A shouldn’t work on service B.

2. Extract Tenant ID from Token, Not URL

Don’t trust tenant IDs from the request path:

# ❌ WRONG: Trust the URL path
when:
- key: request.path
  values: ["/api/tenants/acme/"]

# ✅ CORRECT: Verify against the token claim
when:
- key: request.auth.claims[tenant_id]
  values: ["acme"]

The token is signed by your auth provider and can’t be forged. The URL can be manipulated.

3. Use Deny-by-Default Rules

Start with explicit ALLOW rules for known paths, then deny everything else:

spec:
  action: ALLOW  # Only these rules pass
  rules:
  - ... explicit allow rules ...

# Any request not matching above rules is denied

4. Rotate JWKS Regularly

jwtRules:
- issuer: "https://auth.example.com"
  jwksUri: "https://auth.example.com/.well-known/jwks.json"
  # Istio caches the JWKS for 5 minutes by default
  # Set cache duration via env var:
  # PILOT_JWT_ENABLE_REMOTE_JWKS: "true"
  # PILOT_JWT_REMOTE_JWKS_TIMEOUT: 10s

5. Log Authorization Decisions

Add audit logging to track denials:

# Enable RBAC audit logging
kubectl set env deployment/istio-ingressgateway \
  -n istio-system \
  ENVOY_LOG_DEFAULT=info

Limitations

1. Only Top-Level Claims

AuthorizationPolicy can only match top-level claims. If your JWT has nested data:

{
  "user": {
    "department": "engineering"
  }
}

You can’t directly match user.department. Workaround: Flatten your JWT claims at the auth provider level, or use ext_authz for complex logic.

2. No Negative Matching

You can list allowed values but not disallowed values:

# ✅ Works
when:
- key: request.auth.claims[role]
  values: ["admin", "editor"]

# ❌ Doesn't work
when:
- key: request.auth.claims[role]
  notValues: ["viewer"]

Workaround: Use a DENY-type AuthorizationPolicy for explicit denials.

3. Limited Data Types

AuthorizationPolicy matches only string and array claims. It can’t match:

  • Nested objects
  • Numeric ranges
  • Complex logic (e.g., “expiry is within 7 days”)

Workaround: For complex logic, use ext_authz service.


When to Use Custom Claims vs ext_authz

Use CaseCustom Claimsext_authz
Simple role/tenant checks✅ Use thisOverkill
Complex business logic❌ Can’t do✅ Use this
Database lookups❌ Can’t do✅ Use this
Nested JWT claims❌ Can’t do✅ Use this
Per-user rate limiting❌ Can’t do✅ Use this
Static claim matching✅ Use thisWorks, but slower

Conclusion

Custom JWT claims let you move authorization logic into the token. Instead of hardcoding RBAC rules, your permissions travel with the request. RequestAuthentication validates the token, and AuthorizationPolicy makes decisions based on claims.

For simple cases — role-based access, tenant isolation, audience restrictions — this is powerful and lightweight. For complex logic, fall back to an external ext_authz service.


Related posts: