OAuth2-secured RabbitMQ over AMQP 1.0
Secure Spring Boot 4 messaging over RabbitMQ AMQP 1.0 with OAuth2 and the native spring-rabbitmq-client: same Keycloak scopes, simpler in-place token refresh.
Part of the guide: Spring Boot and OAuth2: a field guide for every surface
Secure the broker, not just the API
Most services authenticate to RabbitMQ with a shared password (guest/guest, or some service
account passed around a team) and decide who-can-do-what in application code. Both habits age badly:
a shared credential is a blast radius, and an application-level permission check is trust on first
read.
There is a cleaner model, and it is the one you already use for HTTP. Make RabbitMQ an OAuth2 resource server: each service authenticates to the broker with a short-lived, scoped access token from your identity provider, and the broker authorizes every publish and consume from the scopes inside that token. When a read-only service tries to publish, the broker refuses it. Not the application. The broker.
This post builds that model over AMQP 1.0, the protocol RabbitMQ 4 speaks natively, with Spring
Boot 4 and the new
spring-rabbitmq-client. The
companion repo is spring-oauth2-amqp-1.0
and runs with one command, mise run demo. (If you want the same model over the classic AMQP 0.9.1
client, that is part 2 of this series; this post stands on its
own.)
The demo is three Spring Boot services on one Keycloak realm. A dispatcher takes an HTTP job and
publishes it to a jobs exchange (scope jobs_write). A worker consumes the job, uppercases
it, and publishes the result to a results exchange (jobs_read, results_write). A reporter
consumes results and logs them (results_read, and nothing else). Each service is a Keycloak
client_credentials client, and RabbitMQ enforces those scopes on every operation, down to the
routing key.
So why a separate write-up for the AMQP 1.0 build? Because the port surprised me. I built the AMQP
0.9.1 version first, then moved it to AMQP 1.0 expecting a rewrite, since the wire protocol is
genuinely different: no channels, no basic.publish, a new addressing scheme. What I got was
stranger and more useful. The security model did not move at all, the client side got smaller, and
exactly one thing actually changed: what happens to a long-lived connection when its token expires.
These are the field notes.
The diff that wasn't
Start with what I did not touch, because that is the surprising part.
The broker configuration is the same file. Byte for byte. The auth backends, the
resource_server_id, the JWKS URL, and the scope aliases that turn jobs_write into
rabbitmq.write:*/jobs/job.* are all unchanged from the 0.9.1 repo:
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/certs
# (the https.peer_verification = verify_none hop for Traefik's self-signed cert, the
# preferred_username_claims, and the read scope_aliases are omitted here for brevity)
auth_oauth2.scope_aliases.jobs_write = rabbitmq.write:*/jobs/job.*
auth_oauth2.scope_aliases.results_write = rabbitmq.write:*/results/result.*AMQP 1.0 needs no plugin. It is native in RabbitMQ 4.x, so there is nothing to enable for the protocol itself. The OAuth2 plugin (
rabbitmq_auth_backend_oauth2) is still what validates the JWT and maps scopes to permissions, and it does not care which protocol carried the token.
The Keycloak realm is the same too: the same confidential client per service, the same
client_credentials grant, the same per-service scopes, and the same audience mapper stamping
aud: rabbitmq onto every token. The topology is the same definitions.json: two topic exchanges,
jobs and results, and two queues, jobs.in and results.out.
The reason all of that survives untouched is the whole idea, stated from the other side: the security boundary lives in the token, not the transport. The broker authenticates a JWT and authorizes a set of scopes; it does not care whether AMQP 0.9.1 or AMQP 1.0 delivered that JWT to it. So when you swap the transport, the boundary does not notice.
That left column is the part nobody warns you about. A protocol migration sounds like it touches everything, and most of what you would worry about does not move.
Less code, not more
The AMQP 0.9.1 build needed a small bridge to get an OAuth2 token onto a broker connection. The
RabbitMQ 0.9.1 Java client asks a CredentialsProvider for a username and password, so there I
implemented one whose password was the JWT, wired Spring Security's OAuth2AuthorizedClientManager
behind it to mint the tokens, and bolted both onto Boot's connection factory with a
ConnectionFactoryCustomizer. It worked, but it was three classes of glue.
On AMQP 1.0 that glue is gone. The client has first-class OAuth2 support, so token acquisition lives on the connection settings, and the whole credential story is one builder block:
return new AmqpEnvironmentBuilder()
.connectionSettings()
.host(connection.host()).port(connection.port()).virtualHost(connection.virtualHost())
.oauth2()
.tokenEndpointUri(oauth2.tokenUri())
.clientId(oauth2.clientId()).clientSecret(oauth2.clientSecret())
.grantType("client_credentials")
.parameter("scope", oauth2.scope()) // jobs_write, expanded by the broker to write:*/jobs/job.*
.shared(true) // one token, refreshed once, shared by every connection
.connection()
.environmentBuilder()
.build();That oauth2() block is the entire credential story. The client fetches the client_credentials
token from Keycloak itself and presents it as the AMQP SASL password, because AMQP 1.0 always
authenticates with SASL and the JWT is the password. There is no OAuth2CredentialsProvider, no
OAuth2AuthorizedClientManager, and no ConnectionFactoryCustomizer. The Spring Security OAuth2
client machinery is not in the broker path at all anymore.
It even kills a bug I had hit on the 0.9.1 client, for free. There, fetching the token over
cleartext behind Traefik deadlocked: the JDK's HttpClient tried an h2c upgrade that silently
hung, and the fix was to drop Apache HttpComponents 5 on the classpath to force HTTP/1.1. The AMQP
1.0 client's token requester pins its JDK HTTP client to HTTP/1.1 by default, so that workaround
never has to exist here.
The catch is that you give up auto-configuration. There is no Spring Boot auto-config for AMQP 1.0,
so the Environment, the connection factory, the template, and the listener factory are all
@Beans you declare yourself. A SingleAmqpConnectionFactory adapts the Environment above to
Spring AMQP:
@Bean
AmqpConnectionFactory connectionFactory(Environment environment) {
return new SingleAmqpConnectionFactory(environment);
}There is one trap hiding in that hand-wiring. The legacy 0.9.1 RabbitTemplate and Channel
classes are still on the classpath, pulled in transitively by spring-rabbitmq-client. Spring Boot
4 moved RabbitAutoConfiguration into a separate spring-boot-amqp module, which this project
deliberately does not depend on. That is what keeps Boot from auto-creating a stray legacy
CachingConnectionFactory pointed at guest/guest that would shadow the AMQP 1.0 beans. Add
spring-boot-starter-amqp and you silently get it back.
So the port is more manual and less code at the same time. You write the plumbing by hand, but the
credential bridge that was the fiddly part of the 0.9.1 demo collapses into one oauth2() call.
Same API, different plumbing
AMQP 1.0 has no channels and no basic.publish. A producer attaches a link to a target address
and a consumer attaches to a source address, and RabbitMQ's
v2 address format maps the familiar model onto them:
publishing job.submitted to the jobs exchange becomes a link to /exchanges/jobs/job.submitted,
and consuming from jobs.in becomes a link from /queues/jobs.in.
You do not write any of that. RabbitAmqpTemplate and @RabbitListener take the same exchange,
routing-key, and queue arguments you already know and build the addresses for you. The consumer is
line-for-line identical to the 0.9.1 version:
@RabbitListener(queues = "${app.amqp.queues.jobs-in}")
public void handle(Map<String, Object> job) { /* ... */ }The publisher takes the same exchange and routing key too, with one difference worth knowing:
RabbitAmqpTemplate.convertAndSend is asynchronous, returning a CompletableFuture you can chain,
where the 0.9.1 RabbitTemplate call returned void:
// same exchange + routing key as before; the send is now async
rabbitTemplate.convertAndSend(exchange, routingKey, payload)
.whenComplete((accepted, throwable) -> { /* log a publish the broker did not settle */ });The protocol changed underneath a near-identical API. That is the boring half of the migration, and boring is exactly what you want from the half that touches every line of business logic.
The one thing that genuinely changed
Here is the delta that actually mattered, and the only part of the port I had to stop and think about.
Token expiry on AMQP 0.9.1 has a nasty wrinkle. When a token expires on a live connection, the
broker does not drop it. It keeps the connection open and refuses each operation with
403 ACCESS_REFUSED, so a long-lived publisher fails silently while the HTTP endpoint in front of
it still returns 202 Accepted. The fix on 0.9.1 is to renew the token in place with the AMQP
update-secret method, driven by a CredentialsRefreshService you add yourself.
AMQP 1.0 does the opposite. When the token on a live connection expires, the broker disconnects the client.
That sounds worse, and in one sense the failure is more aggressive. But a dropped connection is
loud, where a silently refused publish is not, so it is also the more honest of the two. And the
native client turns the fix into a non-event. Configuring oauth2() is the whole thing: the client
schedules a refresh at about 80% of the token's lifetime and re-authenticates the live connection
with an HTTP-over-AMQP PUT /auth/tokens request. No reconnect, no dropped message, no
update-secret, and no refresh() method to implement. That CredentialsRefreshService you would
have wired by hand on 0.9.1 is gone, folded into the same oauth2() block that already
authenticated the connection.
So the failure mode got louder and the fix got simpler. On 0.9.1 I had to add code to survive token expiry. On 1.0 I deleted it.
The integration test proves the broker half of the story directly. It shortens the realm's token lifespan to 15 seconds, opens a connection, publishes once to confirm the fresh token works, then waits past expiry and asserts the broker closed the connection on its own:
const connection = await openConnection("worker", await token("worker"))
let closed: string | null = null
connection.on("connection_close", (ctx) => {
closed = amqpError(ctx.connection?.error).condition ?? "closed"
})
connection.on("disconnected", () => {
closed = closed ?? "disconnected"
})
// the fresh token works
assert.ok((await publishOn(connection, "results", "result.ready")).ok)
// let the 15s token lapse; the broker proactively closes the connection
await sleep((SHORT_TTL + 12) * 1000)
assert.ok(closed !== null, "the broker must disconnect once the token has expired") That disconnect is exactly what the running services never hit, because their in-place refresh renews the token long before it expires. The test forces the failure the production path is designed to avoid, which is the only way to prove the avoidance is real.
The failure shape, too
One smaller change is worth knowing if you write the authorization tests. On 0.9.1 an unauthorized
publish closes the channel with 403 ACCESS_REFUSED. On 1.0 there are no channels, so a refusal
ends the session, and usually the connection, with the OASIS-standard condition
amqp:unauthorized-access.
There is a subtlety underneath that the test had to account for. The exchange-level write check
happens when the link attaches, but the routing-key check happens on the first message transfer.
So when the worker (scope results_write, pinned to result.*) tries to publish internal.audit,
its link to the results exchange attaches fine and the broker only refuses on the send. Same
refusal, two different moments, depending on whether it was the exchange or the routing key that the
token did not cover.
The least-privilege guarantee holds identically. The reporter cannot publish at all, the dispatcher
cannot cross into the results exchange it has no scope for, and the worker cannot use a routing
key outside its result.* grant. The broker refuses each one from the token's scopes, not from any
check in application code. Only the error frame changed its name.
So which one should you reach for?
Since both protocols give you the identical security model, the only real question is which client to build on. My honest take, having now written both:
For a new project on RabbitMQ 4.x and Spring Boot 4, reach for AMQP 1.0. The native client, the
automatic token refresh, and the smaller credential surface are real, and AMQP 1.0 is the protocol
RabbitMQ is putting its weight behind. The hand-wiring is a one-time cost of a single
@Configuration class, and you have seen the whole of it above.
For an existing 0.9.1 system, do not migrate for this alone. spring-boot-starter-amqp is mature,
auto-configured, and sits behind the larger pile of examples and answers. The OAuth2 model is
identical on both sides, so you gain nothing on security by switching. Move to AMQP 1.0 when you
want it for its own reasons, like flow control or cross-broker portability, and treat the simpler
token handling as a bonus rather than the reason.
Either way the part that matters, the security, is the same. That is the whole point of having put it in the token instead of the transport.
Same Keycloak, same scopes, a different transport
The broker config did not move, the realm did not move, and the scopes did not move. Each service is an OAuth2 client, RabbitMQ is the resource server, the JWT rides as the connection credential, and the broker refuses every publish a token is not scoped for. The transport under all of that turned out to be a detail: the Spring client got smaller, the token lifecycle got more honest, and the security model never noticed the protocol change.
This is part 3 of Spring Boot and OAuth2. The foundational model
is in part 2, and the user-facing HTTP edge is in
Token exchange with Spring Cloud Gateway. The AMQP 1.0
source is spring-oauth2-amqp-1.0. Clone
it, run mise run demo, and watch the broker disconnect a connection whose token it no longer
trusts.
Frequently asked questions
What changes when you move OAuth2-secured RabbitMQ from AMQP 0.9.1 to AMQP 1.0?
Almost nothing on the security side. The broker's OAuth2 config, the Keycloak realm, the client scopes, and the exchange/queue topology are identical, because OAuth2 authentication and authorization are protocol-agnostic. What changes is the client: a different Spring library (spring-rabbitmq-client), link-based addressing instead of channels, token acquisition that lives inside the client instead of Spring Security, automatic token refresh, and a broker that disconnects an expired connection instead of refusing operations on it.
How does an AMQP 1.0 client authenticate to RabbitMQ with an OAuth2 token?
The RabbitMQ AMQP 1.0 Java client has first-class OAuth2 support, so you configure it directly on the connection's oauth2() settings: the token endpoint, the client id and secret, and the client_credentials grant. The client fetches the access token itself and presents it as the AMQP SASL password (AMQP 1.0 always authenticates with SASL; the JWT is the password). There is no Spring Security OAuth2AuthorizedClientManager and no hand-written CredentialsProvider in the broker path.
What happens to an AMQP 1.0 connection when its OAuth2 token expires?
The broker disconnects the client. This is the opposite of AMQP 0.9.1, where the broker keeps the connection open and refuses each operation with 403 ACCESS_REFUSED. Because an expired token drops the connection on AMQP 1.0, a long-lived publisher or consumer must refresh the token proactively before it expires, rather than letting it lapse.
How is an OAuth2 token refreshed on a live AMQP 1.0 connection?
The RabbitMQ AMQP 1.0 client does it automatically once oauth2() is configured. It schedules a refresh at about 80% of the token's lifetime and re-authenticates the live connection with an HTTP-over-AMQP PUT /auth/tokens request, with no reconnect and no dropped messages. There is no update-secret method in AMQP 1.0, and no CredentialsRefreshService to wire up; the explicit refresh code the 0.9.1 demo needed is simply gone.
Does RabbitMQ need a plugin to speak AMQP 1.0?
No. AMQP 1.0 is native in RabbitMQ 4.x, so there is nothing to enable for the protocol itself. The rabbitmq_auth_backend_oauth2 plugin is still what validates the JWT and maps scopes to permissions, and its configuration is protocol-agnostic, identical to the AMQP 0.9.1 setup.
What is spring-rabbitmq-client and how does it handle OAuth2?
spring-rabbitmq-client is Spring AMQP's module for RabbitMQ's native AMQP 1.0 protocol, built on the RabbitMQ AMQP 1.0 Java client. It exposes RabbitAmqpTemplate and @RabbitListener over an Environment-based connection, and its oauth2() connection settings fetch the client_credentials token, present it as the SASL password, and refresh it on the live connection automatically. There is no Spring Boot auto-configuration for it, so the Environment and its beans are declared by hand in a small @Configuration class.
Should you use AMQP 0.9.1 or AMQP 1.0 for OAuth2-secured Spring Boot messaging?
For a new project on RabbitMQ 4.x and Spring Boot 4, AMQP 1.0 is the better default: the native client, automatic token refresh, and smaller credential surface are real wins, and it is the protocol RabbitMQ is investing in. For an existing 0.9.1 system, do not migrate for the security model alone, because it is identical either way; spring-boot-starter-amqp is mature and auto-configured. Switch when you want AMQP 1.0 for its own reasons and take the simpler token handling as a bonus.
Resources
Enjoyed this article?
Stay in the Loop
Get notified when I publish new articles. No spam, unsubscribe anytime.