RabbitMQ as an OAuth2 resource server for Spring Boot
Secure your Spring Boot app's messaging with OAuth2, not guest/guest. RabbitMQ joins your HTTP APIs' Spring Security setup as a resource server.
Part of the guide: Spring Boot and OAuth2: a field guide for every surface
You already secure your Spring app with OAuth2
You secure your Spring app's HTTP endpoints with Spring Security's OAuth2 support. Keycloak issues
the token, your app is a resource server that validates the JWT against the JWKS, checks the issuer
and the aud claim, and authorizes each request by the scopes inside the token. That is the
standard setup, and you have probably wired it a dozen times.
Here is the part nobody tells you: a message broker can be an OAuth2 resource server too. The same
Spring app's messaging can be secured the exact same way as its HTTP, with RabbitMQ validating the
same JWTs against the same Keycloak. The broker does not have to be a separate auth island guarded
by guest/guest or a shared service account.
What pushed me to try it: RabbitMQ shipped an OAuth2 auth backend, and on every project I have worked on, broker connections were the awkward exception. We had an IdP, usually Keycloak, sitting right there for the HTTP side. But the broker still got its own technical user, provisioned by hand, shared around, and quietly abused until nobody was sure which service owned which credential. A second login system for the one component that least needed one.
The thing is, an OAuth2 architecture only has four roles: client, authorization server, resource server, and resource owner. So why stand up a separate credential mechanism for the broker at all? Let the broker be the resource server and let the apps talking to it be the clients. That is the whole idea. Here it is.
I built a small demo to prove it to myself. The repo is
spring-oauth2-amqp, and the whole thing runs
with one command.
The broker is just another resource server
Everything in your OAuth2 architecture stays identical between HTTP and messaging except two rows. Here is the whole story in one table.
| Standard OAuth2 (what you know, for HTTP) | The same architecture, reaching the broker |
|---|---|
| Keycloak issues tokens | same Keycloak, same realm |
A service is an OAuth2 client (client_credentials) | same: each service is a client |
A resource server validates the JWT (JWKS signature, issuer, aud) | RabbitMQ is the resource server |
| Authorize by scopes → endpoint authorities | scopes → publish/consume permissions (even per routing key) |
Token rides in the Authorization: Bearer header | token rides as the AMQP connection password |
The top three rows are your existing OAuth2 setup, untouched. The last two rows are what this
article covers. A scope stops mapping to an HTTP endpoint authority and starts mapping to a publish
or consume permission, optionally pinned to a routing key. And the token stops riding in a Bearer
header and starts riding as the AMQP connection password. Spend your attention on those two rows.
Everything else you already know.
See it enforce a scope
Before any config, the shape of it. Three Spring services, one Keycloak, one broker that checks tokens.
A dispatcher accepts an HTTP job and publishes it. A worker consumes the job, uppercases the payload, and publishes a result. A reporter consumes the result and logs it. Each service is a separate Keycloak client with its own scopes, and the broker enforces those scopes on every publish and consume.
Start the whole stack with one command:
mise run demoThen trigger a job over HTTP:
curl -X POST http://localhost:8080/jobs \
-H 'Content-Type: application/json' \
-d '{"payload":"hello amqp"}'
# → 202 {"id":"...","status":"accepted"}The job flows through and the reporter logs the uppercased result. Now the interesting bit: the
reporter holds a results_read scope and nothing else, and as the table promised, that scope maps
to a read permission at the broker, not a write one. If it ever tried to publish, the broker would
close the channel with a 403 ACCESS_REFUSED before a single byte reached an exchange. Not a check
in application code. The broker refuses it. I will prove that with the integration tests further
down, but keep the refusal in mind, because that refusal is what the rest of the article is about.
Keycloak: nothing new here
The Keycloak side is a plain OAuth2 client setup, and that is exactly the point. One realm, one
confidential client per service, the client_credentials grant, and a set of scopes per client. If
you want the full walkthrough of how Keycloak audience mappers and client scopes work, I covered
that in Token exchange with Spring Cloud Gateway. I
won't repeat it here.
The one detail that matters for the broker is the audience. RabbitMQ rejects any token whose aud
claim doesn't contain its resource_server_id. So the setup script adds an oidc-audience-mapper
that injects aud: rabbitmq into every token, and marks it so it never reads as a permission:
await kc.clientScopes.addProtocolMapper(
{ id: audienceScope.id },
{
protocolMapper: "oidc-audience-mapper",
config: {
"included.custom.audience": "rabbitmq",
"access.token.claim": "true",
},
}
)That aud: rabbitmq is the messaging equivalent of scoping a JWT to a downstream API. The audience
scope is assigned as a default to all three clients (include.in.token.scope=false, so rabbitmq
never shows up in the scope claim where it could be misread as a permission).
The per-service scopes are the only thing that differs between clients:
| Service | Keycloak scopes | Can do |
|---|---|---|
| dispatcher | jobs_write | publish jobs |
| worker | jobs_read, results_write | consume jobs, publish results |
| reporter | results_read | consume results, and nothing else |
One more thing worth flagging now: accessTokenLifespan is 300 seconds. Five minutes. That number
is the one to keep an eye on for a long-lived consumer like the worker, and I come back to it just
before the production hardening list.
RabbitMQ as the resource server
This is the first of the two rows that change. Everything RabbitMQ does here is what an HTTP resource server does, expressed for a broker.
It validates JWTs like any resource server
RabbitMQ ships an OAuth2 plugin,
rabbitmq_auth_backend_oauth2 (the name you enable). Once
enabled it registers an auth backend module, rabbit_auth_backend_oauth2 (note: no mq), which you
list in auth_backends in rabbitmq.conf alongside the internal backend, then point at Keycloak's
JWKS endpoint:
auth_backends.1 = rabbit_auth_backend_internal
auth_backends.2 = rabbit_auth_backend_oauth2
auth_oauth2.resource_server_id = rabbitmq
auth_oauth2.jwks_uri = https://traefik/auth/realms/amqp-demo/protocol/openid-connect/certsOn every connection, the OAuth2 backend pulls Keycloak's signing keys, verifies the token signature,
and confirms the aud claim contains rabbitmq. That resource_server_id has to match the aud
value the audience mapper set on the Keycloak side, or the token gets rejected. It is the same
JWKS-signature, same audience check an HTTP resource server runs, just performed by the broker at
connect time.
The internal backend is listed first as a fallback so the management UI stays usable with
admin/admin. That is a convenience for the demo and a thing to remove in production, which I cover
later.
Scopes map to permissions (the messaging twist)
A scope in the token becomes a publish or consume permission on a specific resource, down to the routing key. This is the part worth slowing down for. It's where messaging stops looking like HTTP.
RabbitMQ's native permission format is {permission}:{vhost}/{resource}, with an optional third
segment that restricts the routing key. Rather than stuff that verbose format into Keycloak, the
demo uses scope_aliases to map short scope names onto the native scopes:
auth_oauth2.scope_aliases.jobs_write = rabbitmq.write:*/jobs/job.*
auth_oauth2.scope_aliases.jobs_read = rabbitmq.read:*/jobs.in rabbitmq.configure:*/jobs.in
auth_oauth2.scope_aliases.results_write = rabbitmq.write:*/results/result.*
auth_oauth2.scope_aliases.results_read = rabbitmq.read:*/results.out rabbitmq.configure:*/results.outDecode the first line. The dispatcher's jobs_write scope expands to a write permission, on any
vhost (*), on the jobs exchange, restricted to routing keys matching job.*. That third segment
is doing real work. The dispatcher can publish job.submitted to the jobs exchange, but it cannot
publish to any other exchange, and it cannot use a routing key outside the job.* glob.
That is the messaging analogue of "this scope grants access to POST /jobs and nothing else." The
permission is just expressed in broker terms instead of HTTP terms.
For those permissions to have something to act on, the topology is loaded from definitions.json on
every boot: two durable topic exchanges, jobs and results, and two queues, jobs.in (bound with
job.#) and results.out (bound with result.#). Nothing exotic. The full file is
in the repo.
The services as OAuth2 clients
On the Spring side, each service is a textbook OAuth2 client. Here is the dispatcher's
application.yml:
spring:
security:
oauth2:
client:
registration:
rabbitmq-broker:
provider: keycloak
client-id: dispatcher
client-secret: "${DISPATCHER_CLIENT_SECRET:dispatcher-secret}"
authorization-grant-type: client_credentials
scope:
- jobs_write
provider:
keycloak:
token-uri: http://localhost/auth/realms/amqp-demo/protocol/openid-connect/tokenThe registration id is rabbitmq-broker, the grant is client_credentials, and the only thing that
differs per service is the client-id and the scope list. The worker registration requests
jobs_read and results_write; the reporter requests results_read. There is no user in this
flow, which is exactly right for service-to-service messaging. Each service authenticates as itself.
A small @Configuration builds the manager that actually fetches tokens. It is the
service-to-service manager wired to the client credentials provider:
final var provider = OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();
final var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientService);
manager.setAuthorizedClientProvider(provider); Nothing here is broker-specific yet. If you have ever set up a Spring service that calls another OAuth2 API with client credentials, this is the same code you wrote.
This is also where I lost most of an afternoon, and it had nothing to do with RabbitMQ. The token
POST to Keycloak runs over the JDK's default HttpClient, which negotiates HTTP/2. Behind Traefik,
on cleartext, that means a h2c upgrade, and the upgrade just hung. No error, no timeout I could
read anything into, the token fetch simply deadlocked and the service never finished starting. I
spent hours staring at the wrong layer before the penny dropped that the problem was the protocol
handshake, not OAuth2 and not the broker. The fix was to back Spring's HTTP client with Apache
HttpComponents 5, which negotiates HTTP/1.1 there and skips the upgrade entirely:
<dependency>
<groupId>org.apache.httpcomponents.client5</groupId>
<artifactId>httpclient5</artifactId>
</dependency>One dependency, no code. Spring's RestClient picks up Apache HttpClient 5 when it is on the
classpath, the token POST goes out over HTTP/1.1, and the deadlock is gone. I am leaving this here
because it cost me real time and the symptom points nowhere near the cause.
The one new wire: token as the connection password
Now the second row that changes. The token reaches RabbitMQ as the AMQP connection password, and that one swap is the only genuinely broker-specific code in the whole project.
The RabbitMQ Java client has a CredentialsProvider interface that the connection factory calls to
get a username and password. The trick is to implement it so the password is the OAuth2 access
token:
public class OAuth2CredentialsProvider implements CredentialsProvider {
@Override
public String getUsername() {
return "";
}
@Override
public String getPassword() {
final var request = OAuth2AuthorizeRequest
.withClientRegistrationId(registrationId)
.principal(PRINCIPAL)
.build();
return authorizedClientManager.authorize(request)
.getAccessToken().getTokenValue();
}
}The username is empty. The password is the JWT. Where an HTTP client would set
Authorization: Bearer <token>, the AMQP client hands the same token to the broker as the
connection password. That swap is the whole transport difference between securing HTTP and securing
messaging. (The real provider null-checks the authorize result before reading the token; I trimmed
that guard here for clarity.)
The second piece is how you attach this without throwing away Spring Boot's auto-configuration. The
tempting move is to hand-build a ConnectionFactory, but the moment you define that bean yourself,
Boot backs off and silently drops every other spring.rabbitmq.* setting you have, including SSL
bundles, addresses, and timeouts. Instead, register a ConnectionFactoryCustomizer and only swap in
the credentials provider:
@Bean
ConnectionFactoryCustomizer oauth2CredentialsCustomizer(
OAuth2AuthorizedClientManager authorizedClientManager,
OAuth2AuthorizedClientService authorizedClientService,
AmqpProperties properties) {
return factory -> {
final var provider = new OAuth2CredentialsProvider(
properties.brokerRegistrationId(), authorizedClientManager, authorizedClientService);
factory.setCredentialsProvider(provider);
// the customizer also installs a CredentialsRefreshService; covered in its own section below
};
}Boot still owns the CachingConnectionFactory; the customizer only changes how it authenticates.
While we're on Boot's auto-configuration, one note for anyone on Spring AMQP 4: the message
converter is JacksonJsonMessageConverter, not the Jackson2JsonMessageConverter you may have
muscle memory for. The class was renamed for the Jackson 3 upgrade. The old converter is deprecated
in 4.0 and no longer the default, so reach for the new name.
Proving least privilege
The claim is that the broker, not the application, enforces these boundaries. The way to prove it is to connect to RabbitMQ directly with each service's token and try the publishes the application would never attempt.
The reporter is the clearest case. It holds results_read, consumes results.out, and has
deliberately no RabbitTemplate bean, so the application code cannot publish anything in the
first place. But that is the inner layer. The point of this section is the outer one: even if a bug
introduced a publish call, the broker would refuse it for lack of a write scope.
The integration test is plain Node with amqplib, no test framework. It fetches a real
client_credentials token per service and connects exactly the way the services do, with an empty
username and the token as the password:
return amqp.connect({
hostname: RABBIT_HOST,
username: "", // the token IS the AMQP password
password: await token(clientId),
vhost: "/",
})Then it asserts the matrix. The allowed cases succeed: the dispatcher publishes job.submitted to
jobs, the worker publishes result.ready to results. The refused cases are where the broker
earns its keep:
it("reporter cannot publish (results_read only)", async () => {
const r = await tryPublish("reporter", "results", "result.ready")
assert.match(r.error ?? "", /ACCESS[_-]REFUSED|403/i)
})Three refusals, each a different shape of least privilege:
- The reporter (
results_read) tries to publishresult.readyand is refused. It has no write scope anywhere. - The dispatcher (
jobs_write) tries to publish to theresultsexchange and is refused. Its write scope grants thejobsexchange only, so it cannot cross over. - The worker (
results_write) tries to publishinternal.audittoresultsand is refused. Its write scope is pinned to theresult.*routing key, andinternal.auditfalls outside it.
Every one of those closes the channel with a 403 ACCESS_REFUSED frame. The application never gets
a vote. Run it yourself with mise run demo in one terminal and mise run test:integration in
another.
Keeping the token alive on a long-lived connection
Remember the 300-second token lifespan I told you to keep an eye on? This was the part I worried about most, so I went back and closed it. The failure turned out nastier than I had assumed.
Here is what I had wrong. I figured an expired token would get the connection dropped, and the
client would reconnect with a fresh one. On AMQP 0.9.1, which is what Spring AMQP speaks, that is
not what happens. The broker keeps the connection open and refuses each operation instead. A
basic.publish comes back 403 ACCESS_REFUSED with Provided JWT token has expired at timestamp.
Because the connection never drops, the client never re-authenticates, so it sits there presenting
the same dead token indefinitely.
And it fails quietly. The dispatcher's POST /jobs keeps returning 202 Accepted the whole time,
because it accepts the job and publishes behind the response. The publish is refused, the job is
gone, and nothing surfaces the error. I watched it fail first, with the token lifespan dialed down
to 60 seconds so I would not have to wait five minutes:
operation basic.publish caused a channel exception access_refused:
... "Provided JWT token has expired at timestamp 1780344436"That corrects a guess from an earlier draft of this post: on 0.9.1 the broker refuses operations, it does not disconnect. AMQP 1.0 is the one that proactively drops the connection.
The fix is two small pieces, and it leans on a hook the RabbitMQ Java client already has. First,
OAuth2CredentialsProvider implements two more methods from the client's CredentialsProvider
interface. getTimeBeforeExpiration() reports how long the current token has left, read straight
off Spring's OAuth2AccessToken. refresh() evicts the cached client so the next getPassword()
mints a brand-new token:
@Override
public Duration getTimeBeforeExpiration() {
final Instant expiresAt = currentToken().getExpiresAt();
return expiresAt == null ? Duration.ZERO : Duration.between(Instant.now(), expiresAt);
}
@Override
public void refresh() {
// Evict the cached client so the next getPassword() mints a brand-new token.
authorizedClientService.removeAuthorizedClient(registrationId, PRINCIPAL.getName());
}Second, AmqpConfiguration adds a CredentialsRefreshService bean and sets it on the factory
inside the same customizer that already installed the credentials provider:
@Bean
CredentialsRefreshService credentialsRefreshService() {
// Renews each token once 80% of its lifetime has elapsed (the builder default).
return new DefaultCredentialsRefreshServiceBuilder().build();
}
// ...then, in the customizer that sets the credentials provider:
factory.setCredentialsRefreshService(credentialsRefreshService); Once the provider reports a non-null expiration and a refresh service is present, the RabbitMQ
client registers every connection and, at 80% of the token's lifetime (the builder default), renews
it in place. It calls refresh(), reads the new password, and pushes it to the broker on the open
connection with the AMQP update-secret method. Spring Security mints a fresh token, the client
hands it over, and the broker accepts it without a reconnect. The token still rides as the
connection password. This just swaps in a live one before the old one dies.
The proof is the same shape as the failure. With the fix and a 60-second token, the publishing connection stayed up past three times the token lifetime, with zero refusals, and the broker logged a renewal roughly every 48 seconds, which is 80% of 60:
connection ... user 'dispatcher' updated secret, reason: Refresh scheduled by clientThere is an automated test for it now, in test/test.ts, run with mise run test:integration. It
shortens the realm's token lifespan to 15 seconds and uses amqplib's updateSecret, the same broker
mechanism the services rely on, to prove both halves: that the broker refuses an expired token, and
that an in-place renewal keeps a live connection publishing past expiry.
assert.ok(
(await publishOn(connection, "results", "result.ready")).ok,
"initial publish should succeed"
)
// Before the original token expires, push a fresh one over the open connection,
// exactly what the services' CredentialsRefreshService does at 80% of the token lifetime.
await connection.updateSecret(Buffer.from(await token("worker")), "scheduled token refresh")
// After the original token would have expired, the same connection still publishes fine.
await sleep(10_000)
const renewed = await publishOn(connection, "results", "result.ready")
assert.ok(renewed.ok, `expected publish to succeed after update-secret, got: ${renewed.error}`)One scope note. This stays on AMQP 0.9.1 and Spring AMQP, where update-secret is the renewal
mechanism. RabbitMQ 4.0 and up also speak AMQP 1.0, which proactively disconnects an expired token
instead of refusing operations, and there is a newer spring-rabbitmq-client module for it. That is
a different client API and a full rewrite, so it is a follow-up post rather than a footnote here.
Production hardening
This is a demo built for clarity, and there are gaps you would close before trusting it with anything real. In rough order of how much they would keep me up at night.
Secrets are committed as demo defaults. dispatcher-secret and friends sit in plain text, and
the broker, Postgres, and Keycloak bootstrap all use admin/admin. The services already read their
secrets from ${*_CLIENT_SECRET} env vars and only fall back to the defaults, so moving them to a
secrets manager is a config change, not a code change.
TLS is effectively off. AMQP runs as plaintext on 5672, Keycloak serves plain HTTP, and the
broker's JWKS hop to Keycloak runs with verify_none against a self-signed cert. The bearer token
crosses the wire unencrypted. In production you want a real CA, verify_peer on the JWKS fetch, and
AMQPS end to end.
The internal auth backend is listed first. auth_backends.1 = rabbit_auth_backend_internal
keeps a fully privileged, non-OAuth2 path into the broker open so the management UI works with
admin/admin. For production, drop it or at least stop ordering it first, so OAuth2 is the only way
in.
The dispatcher's /jobs endpoint is unauthenticated. Its SecurityConfiguration is
anyRequest().permitAll(), because this demo is about broker-level auth, not the HTTP edge. In
production you would make the dispatcher an OAuth2 resource server too, which is the user-facing
flow I walked through in
Token exchange with Spring Cloud Gateway.
One Spring app, secured across surfaces
There's no second auth system on the broker. You secured your Spring app's messaging with the same
Spring Security OAuth2 setup that already guards its HTTP: the same Keycloak, the same
client_credentials clients, the same scopes. Only two things changed. Scopes started mapping to
publish and consume permissions instead of HTTP authorities, and the token started riding as the
AMQP connection password instead of a Bearer header.
The HTTP surface of this same story, OAuth2 at the Spring Cloud Gateway, is
Token exchange with Spring Cloud Gateway. The full
source for the messaging surface is
spring-oauth2-amqp. Clone it, run
mise run demo, and watch the broker refuse a publish it was never authorized to make.
Frequently asked questions
Can a message broker be an OAuth2 resource server?
Yes. RabbitMQ's rabbitmq_auth_backend_oauth2 plugin validates a JWT the same way an HTTP resource server does: it checks the signature against the issuer's JWKS and confirms the audience, then authorizes publish and consume operations from the token's scopes. The broker plays the same role a REST API plays in your OAuth2 architecture.
How do OAuth2 scopes map to RabbitMQ permissions?
The broker expands each scope in the token into RabbitMQ's native configure, write, and read permissions on a vhost resource. With scope_aliases you can also restrict a write scope to a specific routing key, so a scope becomes the messaging equivalent of an HTTP endpoint authority, down to the routing key.
Which OAuth2 grant secures service-to-service messaging?
The client_credentials grant. There is no user in the loop: each service authenticates as its own OAuth2 client with a client id and secret and receives a scoped access token. It is the same grant you would use for any service-to-service HTTP call.
How is the OAuth2 token sent to RabbitMQ?
As the AMQP connection password. An HTTP client puts the JWT in an Authorization: Bearer header; a RabbitMQ client sends it as the connection password with an empty username. That transport swap is the only real difference between securing HTTP and securing messaging.
What happens when the OAuth2 token expires on a long-lived RabbitMQ connection?
On AMQP 0.9.1, which Spring AMQP speaks, the broker does not drop the connection. It keeps it open and refuses each operation with 403 ACCESS_REFUSED, so a long-lived publisher silently fails while the HTTP endpoint still returns 202 Accepted. The demo closes this with RabbitMQ's CredentialsRefreshService: the credentials provider reports the token's remaining lifetime, and at 80% of it the client renews the token in place over the open connection via the AMQP update-secret method, so the connection keeps publishing without a reconnect or a lost message.
Why does a Spring service hang while fetching an OAuth2 token behind a reverse proxy?
The JDK's default HttpClient negotiates HTTP/2, and over cleartext behind a proxy like Traefik that means an h2c upgrade, which can silently deadlock so the token fetch never completes and the service never starts. Putting Apache HttpComponents 5 on the classpath makes Spring's RestClient use it, the token POST goes out over HTTP/1.1, and the upgrade is skipped. It is one dependency and no code.
Resources
Enjoyed this article?
Stay in the Loop
Get notified when I publish new articles. No spam, unsubscribe anytime.