Skip to content

reverse_tunnel: add inline JWT authentication for the handshake#46183

Open
kanurag94 wants to merge 7 commits into
envoyproxy:mainfrom
kanurag94:reverse-tunnel-handshake-auth
Open

reverse_tunnel: add inline JWT authentication for the handshake#46183
kanurag94 wants to merge 7 commits into
envoyproxy:mainfrom
kanurag94:reverse-tunnel-handshake-auth

Conversation

@kanurag94

@kanurag94 kanurag94 commented Jul 16, 2026

Copy link
Copy Markdown
Member

Commit Message: reverse_tunnel: add inline JWT authentication for the handshake

Additional Description:

Adds an optional jwt_validation block to the reverse_tunnel network filter.

Problem.
The filter can validate the node/cluster/tenant IDs a downstream claims in its handshake, but can't authenticate them against a credential the caller actually proves it holds.

The natural answer is mTLS, and where the initiator terminates TLS directly on the responder it works well (though after taking care of minting certs etc)—bind a claimed ID to %DOWNSTREAM_PEER_URI_SAN%. But it falls short in two common cases:

  • deployments whose credential is an IdP-issued bearer token rather than a client cert
  • topologies with a TLS-terminating hop (LB, mesh sidecar) between initiator and responder — there the peer SAN is the hop's identity, not the initiator's, so it can't authenticate the real caller. A bearer token, by contrast, rides end-to-end in the handshake and survives the hops.

jwt_authn can't fill the gap either: it's an HTTP filter, and splicing an HCM in front via an internal listener hands the filter a user-space connection with no real fd, which breaks socket registration (the tunnel never becomes usable). So verification has to happen inside the filter, on the real connection.

What it does.
When jwt_validation is set, on each handshake the filter:

  1. Takes the bearer token from the authorization header (header name configurable).
  2. Verifies it against an inline local_jwks — signature, exp/nbf (configurable clock_skew_seconds), iss, and aud. An issuer is required, and a token without exp is rejected.
  3. On failure, replies 401 and closes — before the socket is registered, so a bad token leaves no usable tunnel.
  4. On success, publishes the claims as dynamic metadata, so the existing validation block can bind a claimed ID to a verified claim, e.g.
    tenant_id_format: "%DYNAMIC_METADATA(...reverse_tunnel.jwt:tenant)%".
flowchart LR
    HS["handshake + Bearer JWT"] --> JWT["verify JWT"]
    JWT -- fail --> R1["401 · not registered"]
    JWT -- ok --> BIND["claimed id == verified claim"]
    BIND -- mismatch --> R2["403 · not registered"]
    BIND -- ok --> REG["register socket"]
Loading

Why a scoped config (not JwtProvider).
Most of jwt_authn's JwtProvider — remote/async JWKS, query-param/cookie extraction, upstream forwarding, route cache — is meaningless for an L4 pre-registration handshake. jwt_validation is a small, purpose-built message; the security-relevant semantics (required issuer and exp, normalized audience, clock skew) mirror jwt_authn.

Notes.

  • With no jwt_validation configured, behavior is unchanged.
  • A valid token only proves possession, not the claimed identity — step 4's binding is what authenticates it; without it, a token minted for one tenant can open a tunnel claiming another. Documented on the field.
  • allow_missing_or_failed is an audit mode: failures are counted (jwt_would_deny) but not blocked.
  • Verification reuses //source/common/jwt (synchronous local_jwks only; remote_jwks is a follow-up). The thin sync wrapper stays in the filter for now, with a TODO to promote it to a shared helper if a second L4 consumer appears.

Risk Level:
Low — opt-in experimental field. One change rides along: %REQ(...)% in a validation format string used to render empty and silently pass because the handshake's request headers weren't wired into the formatter; they are now, so it actually evaluates. It was effectively unusable before, so nothing should depend on the old behavior.

Testing:
ReverseTunnelJwtTest in //test/extensions/filters/network/reverse_tunnel:filter_unit_test — acceptance (default header, bare token, custom header); every rejection path (missing, malformed, forged signature, expired, no-exp, wrong issuer, wrong audience) asserting the socket isn't registered; claim binding (accept + 403 on mismatch); audit mode; and config-load failures (missing issuer, unparseable JWKS).

Docs Changes:
Proto fields documented inline.

Release Notes:
Changelog fragment under new_features (new reverse_tunnel area).

Platform Specific Features:
None.

Runtime guard:
None — gated by jwt_validation proto field.

API Considerations:
Additive v3 change — new JwtHandshakeValidation message + ReverseTunnel.jwt_validation field; nothing existing changes. Uses only config.core.v3.DataSource (already imported), so the generated API build is untouched. Deliberately not jwt_authn.v3.JwtProvider, but mirrors its security-relevant semantics.

@repokitteh-read-only

Copy link
Copy Markdown

CC @envoyproxy/api-shepherds: Your approval is needed for changes made to (api/envoy/|docs/root/api-docs/).
envoyproxy/api-shepherds assignee is @mattklein123
CC @envoyproxy/api-watchers: FYI only for changes made to (api/envoy/|docs/root/api-docs/).

🐱

Caused by: #46183 was opened by kanurag94.

see: more, trace.

@kanurag94
kanurag94 marked this pull request as draft July 16, 2026 15:45
@kanurag94
kanurag94 force-pushed the reverse-tunnel-handshake-auth branch from ccfbced to ee1c9c2 Compare July 17, 2026 10:36
The responder-side reverse_tunnel filter validates the node, cluster and tenant
ids a downstream claims in its handshake, but cannot authenticate them against a
verified credential. jwt_authn only runs as an HTTP filter, and fronting the
handshake with an HTTP connection manager over an internal-listener splice breaks
socket registration: the filter must own the real downstream file descriptor to
register the socket for reuse, and a spliced internal connection is user-space
with no descriptor.

Add an optional `jwt_validation` block that verifies the handshake's bearer token
on the real socket, before the connection is accepted and its socket registered,
so a forged or expired token cannot establish a usable tunnel. Verification
(signature, exp/nbf with configurable clock skew, issuer, audiences) reuses
//source/common/jwt; an issuer is required and tokens without an exp claim are
rejected. On success the verified claims are published as dynamic metadata, so the
existing `validation` block can bind a claimed id to a verified claim, e.g.
`tenant_id_format: "%DYNAMIC_METADATA(...reverse_tunnel.jwt:tenant)%"`.

The config is scoped rather than reusing jwt_authn's JwtProvider, whose
remote/async JWKS, request-param and cookie extraction and upstream-forwarding
fields are meaningless for an L4 pre-registration handshake; the security-relevant
semantics mirror jwt_authn.

`allow_missing_or_failed` runs verification in audit mode: it counts would-be
denials via the jwt_would_deny stat without closing the connection.

Where the initiator can present a client certificate, mTLS with
`%DOWNSTREAM_PEER_URI_SAN%` binds identity without this feature; jwt_validation
targets the bearer-token case.

Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>
@kanurag94
kanurag94 force-pushed the reverse-tunnel-handshake-auth branch from ee1c9c2 to 74d21db Compare July 17, 2026 10:46
@kanurag94 kanurag94 changed the title reverse_tunnel: add experimental inline JWT handshake authentication reverse_tunnel: add inline JWT authentication for the handshake Jul 17, 2026
@kanurag94
kanurag94 marked this pull request as ready for review July 17, 2026 11:07
@yanavlasov yanavlasov assigned agrawroh and unassigned mattklein123 Jul 17, 2026

@basundhara-c basundhara-c left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a useful addition; We'd hoped to keep the tunnel filter lean and do JWT in a preceding filter, but I understand that it might be difficult given the duplicate() semantics and that jwt_authn is L7.

ENVOY_LOG(debug, "reverse_tunnel: jwt: missing token header '{}'", jwt_token_header_.get());
return false;
}
absl::string_view token = token_values[0]->value().getStringView();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We ignore additional token headers, worth adding a comment here or, logging a warning if token_values.size() > 1

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added


// Success: publish verified claims as dynamic metadata so the validation block can bind a claimed
// handshake id to a verified claim via %DYNAMIC_METADATA(namespace:claim)%.
stream_info.setDynamicMetadata(jwt_claims_namespace_, jwt.payload_pb_);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we publish the claim before validateIdentifiers(), so it's published even if validation fails and a 403 is sent out. Worth a comment.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added a comment

// Validate node_id if formatter is configured.
if (node_id_formatter_) {
const std::string expected_node_id = node_id_formatter_->format({}, stream_info);
const std::string expected_node_id = node_id_formatter_->format(context, stream_info);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for adding this! We should add a unit test for this as well; add a validation config with %REQ(x-header)%, one handshake where the header matches succeeds and one where it doesn't fails.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for pointing out, added.

Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>

@wbpcode wbpcode left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lgtm api

@basundhara-c

Copy link
Copy Markdown
Contributor

@agrawroh this looks good to me, could you have a final look at it?

@kanurag94

Copy link
Copy Markdown
Member Author

/retest

Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>
… auth

Inline JWT handshake validation currently accepts only a synchronous
`local_jwks`, modeled as a bare required field. The planned follow-up adds
asynchronous `remote_jwks`, a mutually-exclusive alternative JWKS source.

Wrap `local_jwks` in a `jwks_source_specifier` oneof now, while the feature is
experimental and unreleased, so `remote_jwks` can land later as a purely
additive arm (field 8) rather than forcing a schema reshuffle. Field number 3
is preserved, so the change is wire-compatible; the field-level `required` rule
becomes an oneof-level `(validate.required)`, preserving the "a JWKS source must
be present" guarantee. This mirrors jwt_authn's own `jwks_source_specifier`.

No behavioral change: with a single arm the `local_jwks()` accessor and the
config-load path are unchanged. Add a config-load test asserting that a
`jwt_validation` block with no JWKS source is rejected, locking in the oneof
contract so a future `remote_jwks` arm cannot silently make the source optional.

Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>
Signed-off-by: Anurag Aggarwal <kanurag94@gmail.com>
@agrawroh
agrawroh enabled auto-merge (squash) July 23, 2026 10:49
@agrawroh

Copy link
Copy Markdown
Member

/retest

@wbpcode
wbpcode disabled auto-merge July 23, 2026 12:56
@wbpcode

wbpcode commented Jul 23, 2026

Copy link
Copy Markdown
Member

/wait a discussion about the API :)

Signed-off-by: wbpcode <wbphub@gmail.com>
@kanurag94

Copy link
Copy Markdown
Member Author

/retest

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants