JWTs aren’t just for signing requests; they’re the core of how Envoy can authorize requests based on identity baked into the token itself.
Let’s see this in action. Imagine an incoming request to your service, something like:
GET /api/users HTTP/1.1
Host: example.com
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf3-30342a38f45d2918d40324f5f10b0570
Envoy, configured as a transparent proxy, intercepts this. It doesn’t need to ask your upstream service "who is this user?" Instead, it can inspect the Authorization header, pull out that JWT, and verify it using a public key or a shared secret. If the JWT is valid, Envoy can then either allow the request to pass to the upstream service, or add specific headers to the request (like x-user-id: 1234567890) so the upstream service knows who made the request without having to do any JWT parsing itself.
Here’s how Envoy handles JWT verification: it uses the ext_authz filter, which can be configured to call out to an external authorization service. This service is responsible for validating the JWT. Envoy doesn’t directly parse JWTs in its core configuration; it delegates that to another component. The ext_authz filter acts as the bridge, sending the token to an external service and then acting on the response.
The real magic is in the external authorization service. This service receives the JWT from Envoy. It then uses a JWT library (like python-jwt or node-jsonwebtoken) to perform the checks:
- Signature Verification: It checks if the JWT was signed by the expected party. This involves fetching the public key (if using asymmetric crypto like RS256) or using the shared secret (if using symmetric crypto like HS256).
- Expiration: It verifies that the
exp(expiration time) claim in the JWT is in the future. - Audience (
aud): It checks if the JWT was intended for this specific service (the audience). - Issuer (
iss): It can verify who issued the token. - Other Claims: Any other custom claims can be checked for specific authorization rules.
If all checks pass, the authorization service returns an OK (HTTP 200) status to Envoy. Envoy then allows the request to proceed, potentially adding claims from the JWT as headers to the upstream request. If any check fails, the authorization service returns an Unauthorized (HTTP 401) or Forbidden (HTTP 403) status, and Envoy will reject the request.
Consider this Envoy configuration snippet for the ext_authz filter:
name: envoy.filters.http.ext_authz
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
grpc_service:
envoy_grpc:
cluster_name: authz_service
timeout: 0.5s
transport_api_version: V3
with_request_body:
max_request_bytes: 8192
allow_partial_message: false
pack_as_bytes: false
include_peer_certificate_isbn: false
filter_enabled:
default_value: true
runtime_key: ext_authz_filter_enabled
check_request_headers:
- header:
name: "authorization"
# If the header is not present, the request is denied by default.
# You can change this behavior by setting 'optional: true'
optional: false
The authz_service cluster (not shown here) would point to your actual authorization server. The ext_authz filter is configured to look for the authorization header. If it’s missing, the request is denied. The filter sends the entire request (or just headers, depending on configuration) to the authz_service.
The external authorization service would then perform the JWT validation. For example, if using Node.js with jsonwebtoken, the validation might look like this:
const jwt = require('jsonwebtoken');
const jwksClient = require('jwks-rsa');
// Assuming you have the JWT from the Authorization header
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...';
const expectedAudience = 'my-api';
const issuer = 'https://my-auth-provider.com';
// For RS256, you'd fetch the public key
const client = jwksClient({
jwksUri: 'https://my-auth-provider.com/.well-known/jwks.json'
});
function getKey(header, callback) {
client.getSigningKey(header.kid, function(err, key) {
const signingKey = key.publicKey || key.secret;
callback(null, signingKey);
});
}
jwt.verify(token, getKey, { audience: expectedAudience, issuer: issuer, algorithms: ['RS256'] }, function(err, decoded) {
if (err) {
// Token is invalid - return 401/403 to Envoy
console.error('JWT verification failed:', err);
} else {
// Token is valid - extract claims and return success to Envoy
console.log('JWT verified:', decoded);
// Envoy will receive HTTP 200 OK
}
});
A common pitfall is assuming Envoy itself is doing the JWT parsing. It’s not; it’s a delegation pattern. The ext_authz filter is the key component, but the actual JWT logic lives in a separate service.
The next step after verifying JWTs is often to use the claims within the JWT to make fine-grained authorization decisions, rather than just simple authentication.