Skip to content

Add a SaslTermination filter - #124

Merged
tombentley merged 52 commits into
kroxylicious:mainfrom
tombentley:sasl-termination-proposal
Aug 3, 2026
Merged

Add a SaslTermination filter#124
tombentley merged 52 commits into
kroxylicious:mainfrom
tombentley:sasl-termination-proposal

Conversation

@tombentley

@tombentley tombentley commented Jul 20, 2026

Copy link
Copy Markdown
Member

Summary

This proposal adds a SaslTermination filter that authenticates Kafka clients directly at the proxy, without forwarding SASL exchanges to the upstream broker. This enables credential isolation, authentication protocol translation, and centralized credential management.

Motivation

Some routing implementations depend on knowing the client's Subject, but mechanisms like SCRAM-SHA are incompatible with the existing SASL inspection technique (each server issues a unique challenge, so SASL Authenticate can't be fanned out). SASL termination solves this by having the proxy perform the full SASL exchange itself.

Scope

The proposal covers:

  • Support for SCRAM-SHA-256/512, and OAUTHBEARER
  • A state machine managing the SASL exchange lifecycle
  • Timing side-channel mitigation via fixedAuthDelay
  • Reauthentication support

DoS protection (rate limiting / exponential backoff) and validation of legal SASL filter compositions within filter chains are out of scope.

Part of kroxylicious/kroxylicious#4391

@tombentley
tombentley force-pushed the sasl-termination-proposal branch from 4c4b59e to 80fbff1 Compare July 21, 2026 01:48
@tombentley
tombentley marked this pull request as ready for review July 21, 2026 02:31
@tombentley
tombentley requested a review from a team as a code owner July 21, 2026 02:31
@tombentley

Copy link
Copy Markdown
Member Author

Please can someone review this PR? @k-wall @SamBarker @robobario

@SamBarker SamBarker 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.

Thanks for putting this together Tom — there's clearly a lot of thorough thinking here.

I'm going to be honest: I've spent a good amount of time on this and I haven't made it much past the SaslTerminationConfig section. There's a wall of detail that makes it hard to form a high-level picture of the design before getting pulled into component-level specifics.

I think the proposal would benefit from a 40,000 ft overview early on — something showing how the components compose, what calls what, and where the module boundaries are. Without that, I'm left assembling the big picture from seven component descriptions, and I'm not confident I have it right.

I'll continue working through the rest of the proposal, but wanted to get these comments posted rather than sit on them.

Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md Outdated

**`MechanismHandler`** -- handles the authentication exchange for a single connection. Instances are per-connection and not thread-safe.

```java

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.

The name MechanismHandler doesn't convey what this component actually does. From Kroxylicious's perspective, the filter's job is to validate client credentials — whether that takes one round (OAUTHBEARER) or multiple (SCRAM) is an implementation detail of how the validation happens, not a different responsibility.

I understand "mechanism" comes from the SASL spec, but in our codebase the meaningful concept is credential validation. Something like CredentialValidator / CredentialValidatorFactory would make the role immediately clear to someone reading the code without SASL RFC context.

I'm also a bit wary of "Handler" given our Netty context — it invites confusion with ChannelHandler, which is a different thing entirely.

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.

The thing which defined and uses the MechanismHandler interface is the SaslTermination filter. That's the relevant perspective by which we should judge the suitability of names. From that PoV SASL is a given, and so using SASL specific terms, like 'mechanism', in named seems entirely appropriate.

I'm open to suggestions about 'handler'. 'Callback' is okayish, but doesn't quite feel right given how it's used.

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.

I still think naming in terms of roles (what the component does) rather than protocol concepts (where it comes from) would serve readers better, but I don't feel strongly enough to hold up the proposal. A few alternatives to "Handler" if you're open to it:

  • MechanismAuthenticator — direct role-based, it authenticates
  • MechanismExchange — leans into SASL spec language, one instance per connection maps to one exchange per connection
  • MechanismValidator — what it ultimately decides: are these credentials valid?

For what it's worth, "Mechanism" feels redundant in the impl names — you already know it's SCRAM-SHA-256:

Interface Impl
MechanismAuthenticator ScramSha256Authenticator
MechanismExchange ScramSha256Exchange
MechanismValidator ScramSha256Validator

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.

Noted. If we drop the service loading part these are gonna be internal interfaces (not public API), so we're at liberty to decide when a code PR shows up.

Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
@tombentley

Copy link
Copy Markdown
Member Author

Thanks for taking a look @SamBarker, I'll try to address your comments next week, but:

I think the proposal would benefit from a 40,000 ft overview early on — something showing how the components compose, what calls what, and where the module boundaries are.

I added a UML diagram which renders when you view the rendered page (but not in the code review view):

@SamBarker

Copy link
Copy Markdown
Member

Thanks Tom. I was working from a local checkout and only found the updates after I'd submitted my comments.

I found the single UML diagram hard to parse — it covers module boundaries, inheritance hierarchies, and runtime interactions all in one. I had a go at breaking it down into separate views at different levels of detail: https://gist.github.com/SamBarker/eddb68a6797c43bda4cbaee4bf9043bf

I think some of these would be worth including in the proposal but not necessarily all of them. Have a look and pick whichever ones you think are strongest?

@tombentley
tombentley force-pushed the sasl-termination-proposal branch 2 times, most recently from c34ac50 to 0d5f655 Compare July 27, 2026 05:22

@SamBarker SamBarker 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.

Continuing the review — comments on the remaining components, credential store implications, and system-level failure modes.

Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md
The SASL termination filter emits additional metrics for authentication latency and session expiry.

| Metric | Type | Tags | Description |
|--------|------|------|-------------|

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.

Might be useful to have a counter for unauthorized RPCs that arrive before the user is authenticated.

- The security barrier is enforced for all non-SASL request types. Unauthenticated requests are rejected and the connection is closed.
- On authentication failure, the connection is closed immediately.

### CLI tool security

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.

we mention security quite a bit, should we put something up top near the summary as a statement about our overall security posture for this solution. It feels like implicitly we value this solution being secure because of the Authn/Authz realm it sits in, and we are choosing to diverge from what Kafka offers (SCRAM password minimum lengths, SASL Plain) because of this unwritten posture. I think it's worth being explicit.

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.

It feels like implicitly we value this solution being secure because of the Authn/Authz realm it sits in

Well, yes, there's not point string to terminate SASL if we do so with glaring flaws.

and we are choosing to diverge from what Kafka offers (SCRAM password minimum lengths, SASL Plain)

FWIW:

  • Minimum password length is just enforcing NIST recommendations, IIRC. We don't have to do that, but I think it moves us closer to being able to claim "secure by default". Also, since NIST now recommends against forced password rotation the JSON lacks a last_changed_date. But these things are always trade-offs. It seems possible that someone will come along with a compliance requirement to enforce password ageing. It doesn't have to be perfect right now.
  • The choice to use a larger iteration count comes from OWASP. It basically boils down to making brute force attacks (whether online or offline) both and slower and more mostly.
  • SASL Plain: We could offer it, of course. It's not ruled out by this proposal. But I'd rather keep this proposal small. SCRAM provides a simple, password-based mechanism without requiring additional infra (c.f. OAUTHBEARER) which is just as usable from the PoV of a app developer writing a Kafka application. So I don't see a good use case for PLAIN.

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.

👍 I should have been clearer, I agree with the proposal diverging from Kafka, using more up to date security requirements than whatever Kafka chose back in the day. Was just asking for a sentence somewhere saying, "since this is in the security domain we're going to adopt current/stronger security advice rather than support everything Apache Kafka does"

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 an explicit security posture statement at line 62. Addressed in 30f58af.


| From state | Triggering event | To state |
|------------|------------------|----------|
| **RequiringHandshake** | `SASL_HANDSHAKE` with supported mechanism | **RequiringAuthenticate** |

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.

might be worth adding the unsupported mechanism case stating we remain in RequiringHandshake and returns UNSUPPORTED_SASL_MECHANISM with supported list. Which looks like what's in the PoC

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.

+1

| Threat | Mitigation |
|--------|------------|
| Token from wrong audience or issuer -- a JWT issued for a different service or identity provider is presented to the proxy. | Both `expectedAudience` and `expectedIssuer` are required fields. The handler rejects tokens that do not match. |
| JWKS endpoint compromise -- an attacker controls the JWKS endpoint and supplies signing keys for forged tokens. | Mitigated operationally: the JWKS endpoint URL is set by the proxy administrator, not by clients. TLS protects the endpoint in transit (using the JVM's default trust store). |

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.

something claude picked out of your PoC is that we should be careful with logging or returning error messages to the client which could contain details like "JWT expired at...", "wrong audience", or "wrong issuer" . Giving the client information about whether they've got the right IdP but wrong claims and so on could be a risk. Similar to scram I think these should be hidden behind a generic "Authentication failed".

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 to the OAUTHBEARER threats table: the filter returns a generic "Authentication failed" to the client, matching the SCRAM approach. Addressed in 2e29fe2.

Comment thread proposals/124-sasl-termination.md Outdated

---

### Component 4: OAUTHBEARER mechanism handler

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.

In the PoC the oauth handler factory is manipulating the org.apache.kafka.sasl.oauthbearer.allowed.urls system property as part of the interaction with the apache kafka components, I think this is worth calling out somewhere as a challenge of integrating with the kafka internals and why we diverge from using our own sys prop or other proxy-wide configuration.

Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
@k-wall

k-wall commented Jul 29, 2026

Copy link
Copy Markdown
Member

The proposal probably ought to say the validating legal compositions of SASL Filters within the filter chains is not in scope for this proposal.

@k-wall

k-wall commented Jul 29, 2026

Copy link
Copy Markdown
Member

Clients in authentication fail loops are common place in production systems. There's also DoS issue possibility. As the proposal stands the client's reuath attempt will be slowed by fixedAuthDelay. Is this sufficient? Should we be thinking about exponential delay strategy (username or token based, the latter like io.kroxylicious.filter.oauthbearer.OauthBearerValidationFilter#rateLimiter)

@k-wall k-wall 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.

Very thorough work @tombentley. I've left some comments to consider.

Comment thread proposals/124-sasl-termination.md Outdated

@tombentley tombentley left 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.

Thanks for the reviews @SamBarker @robobario @k-wall, I think I've addressed many of them, so please take another look when you have time.

Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md Outdated
Comment thread proposals/124-sasl-termination.md
@k-wall

k-wall commented Jul 30, 2026

Copy link
Copy Markdown
Member

Thanks @tombentley, happy to approve but for the absence of a response on the DoS possibility. It would be good to see unsupport mech handling in the state machine too.

Can I also ask you add a very quick summary to the description field of this PR too explain the motivation etc?

@tombentley

Copy link
Copy Markdown
Member Author

Sorry @k-wall:

Clients in authentication fail loops are common place in production systems. There's also DoS issue possibility. As the proposal stands the client's reuath attempt will be slowed by fixedAuthDelay. Is this sufficient? Should we be thinking about exponential delay strategy (username or token based, the latter like io.kroxylicious.filter.oauthbearer.OauthBearerValidationFilter#rateLimiter)

fixedAuthDelay is specifically a timing side-channel mitigation, not a DoS countermeasure. I think DoS protection via rate limiting or quotas would be better served by a dedicated filter that can be reused outside of SASL termination.

AlterUserScramCredentials is rejected (credential store SPI is
read-only). DescribeUserScramCredentials can be answered by the
filter from its own credential store since the response contains
only mechanism and iteration count.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
SASL termination splits authentication into two independent exchanges.
The client can succeed against the proxy while the proxy fails to
authenticate upstream. Discusses potential solutions (triggering
upstream auth verification, SaslInitiator filter) but defers to
future work due to complexity.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Upstream auth failure visibility is an open design question, not a
known limitation. Moved to a new Open Questions section describing
the problem and potential approaches (SaslInitiator filter,
indistinguishable failure responses) without making a decision.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
The filter intercepts all requests, not just SASL ones. Non-SASL
requests are checked for authentication state and session expiry
as a security barrier. The unauthenticated check is defence in
depth since failed auth already closes the connection.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Reject and close the connection if SASL_HANDSHAKE or
SASL_AUTHENTICATE arrives with an API version outside the range
known to the filter, preventing future protocol versions from
bypassing security logic.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Add optional subjectBuilder config option defaulting to
DEFAULT_SUBJECT_BUILDER, consistent with SaslInspection filter.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
…nator

Replace Map<String, MechanismConfig> with List<MechanismConfig> using
Jackson name-based polymorphism on a 'mechanism' property containing
the IANA-registered mechanism name. Gives better error messages and
is consistent with other definition lists in the project.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
ScramMechanismConfig becomes an abstract sealed base class whose
constructor accepts the mechanism name. ScramSha256MechanismConfig
and ScramSha512MechanismConfig are trivial subclasses, allowing
Jackson name-based polymorphism to distinguish the two variants.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Once authenticated with no session expiry, the filter uses
shouldHandleRequest to skip deserialization of subsequent
requests, avoiding unnecessary filter overhead in steady state.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Note that Kafka's ScramSaslServer.dispose() is a no-op, confirming
that the lack of dispose() on raw connection close is safe for the
built-in handlers.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Acknowledge the asymmetry between SCRAM's pluggable credential
store and OAUTHBEARER's hardcoded validator. A TokenValidator SPI
is planned as future work to enable token introspection and custom
claim validators.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Kafka does not expose __cluster_metadata as a consumable topic,
and DescribeUserScramCredentials deliberately does not return
credential material. SCRAM credentials are write-only by design,
so a broker-backed credential store is not possible.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Use Base64URL-encoded SHA-256 hash of the username as the KeyStore
alias instead of the raw username. JKS/PKCS12 are case-insensitive,
keytool fails on special characters, non-ASCII is corrupted across
tools, and Kafka does not apply SASLprep so any UTF-8 bytes can
appear in usernames.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Base64 is unsuitable because JKS/PKCS12 are case-insensitive.
Use lowercase hex-encoded SHA-256 instead (64 chars, no case
sensitivity issues).

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Include a version field in the serialized JSON to allow the
format to evolve while maintaining backwards compatibility.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Explicitly name the trust boundary shift: the proxy is now the
authentication boundary and custodian of credential material.
Recommend a comprehensive threat model as a separate activity.

Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Assisted-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
…nism to state machine, restructure filter description

Make mechanism handling internal to the filter rather than describing
it as an SPI. Remove Component 2 (MechanismHandler extension point)
and all references to internal types (MechanismHandler,
MechanismHandlerFactory, MechanismConfig). Renumber components 3-7
to 2-6.

Add unsupported mechanism handling to the state machine table and
diagram: RequiringHandshake stays in RequiringHandshake, responds
with UNSUPPORTED_SASL_MECHANISM and the supported mechanism list.

Restructure Component 1 around request types (API_VERSIONS,
SASL_HANDSHAKE, SASL_AUTHENTICATE, other) so the filter behaviour
follows the client request sequence rather than listing features
in isolation.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
State upfront that the proposal adopts current security guidance
(NIST SP 800-63B, OWASP) rather than matching Kafka's historical
defaults.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
JWT validation failure details (expired token, wrong audience, wrong
issuer) must not be returned to the client. Use a generic
"Authentication failed" message, matching the SCRAM approach.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Kafka's OAuthBearerValidatorCallbackHandler requires the
oauthbearer.allowed.urls system property to be set. This is a
JVM-global side effect that must be called out as a Kafka internal
API dependency.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Add --iterations option to add-user and update-password CLI commands
(default 10,000, minimum 4,096). Update list-users to show mechanism
and iteration count for credential auditing.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Document the risk of offline password recovery after credential store
exfiltration, with mitigations: configurable iteration count, file
permission checks, and KeyStore encryption for in-transit protection.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Clarify that fixedAuthDelay is a minimum duration (floor), not an
additive delay. For SCRAM, unknown users must not fail fast on the
first round but continue into the second round to prevent
round-trip-based user enumeration.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
Add per-mechanism authBytes limits (SCRAM: 4KB, OAUTHBEARER: 128KB)
and a 255-character username length limit enforced in both the SCRAM
handler and CLI tool.

Assisted-by: Claude Opus 4.6 <noreply@anthropic.com>
Signed-off-by: Tom Bentley <tbentley@redhat.com>
@tombentley
tombentley force-pushed the sasl-termination-proposal branch from 882ec44 to 7725263 Compare August 3, 2026 04:46

@k-wall k-wall 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.

Nice work @tombentley. Happy to see this merged.

@tombentley
tombentley merged commit 94b4daf into kroxylicious:main Aug 3, 2026
2 checks passed
@github-project-automation github-project-automation Bot moved this from Must Do to Done in Release 0.24.0 Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Add a Filter implementing SASL Termination

4 participants