A Spring Boot demo that secures both broker connections and per-message permissions with OAuth2,
over the AMQP 1.0 protocol.
No guest/guest. No application-level ACLs. Just tokens, scopes, and a broker that refuses to lie.
Read the companion blog post →
This is the AMQP 1.0 port of
spring-oauth2-amqp-0.9.1(which uses AMQP 0.9.1). Same security model, same topology, same payoff — adapted to AMQP 1.0's native client, addressing, and token lifecycle. See What changed from the AMQP 0.9.1 version.
Most RabbitMQ tutorials authenticate services with guest/guest (or some other shared password) and enforce
who-can-do-what inside the application. Both habits are bad: shared credentials are a blast radius, and
application-level ACLs are trust-on-first-read.
This repo shows a cleaner model — every service authenticates to the broker with an OAuth2 access token, and RabbitMQ authorizes each publish and consume based on the scopes inside that token. When the reporter service (which is only supposed to read results) tries to publish, the broker rejects the attempt. Not the application. The broker.
Traefik (reverse proxy)
│ /auth
▼
Keycloak (HTTP, behind Traefik) ── Postgres
├── client: dispatcher (scopes: jobs_write)
├── client: worker (scopes: jobs_read, results_write)
└── client: reporter (scopes: results_read)
RabbitMQ 4.2 (native AMQP 1.0 + OAuth2 plugin → JWKS via Traefik over TLS)
├── exchange: jobs (dispatcher writes, worker consumes via jobs.in)
└── exchange: results (worker writes, reporter consumes via results.out)
Services (Spring Boot 4, Spring AMQP 1.0 client)
├── dispatcher (:8080) REST trigger → publish to jobs
├── worker consume jobs → publish to results
└── reporter consume results (publish attempts rejected by broker)
Each service connects to the broker over AMQP 1.0 using the
RabbitMQ AMQP 1.0 Java client (via Spring AMQP's
spring-rabbitmq-client module). The client is configured with Keycloak's token endpoint and the service's
client_credentials, so it fetches an access token itself and presents it as the AMQP SASL credential (the JWT is
the password; AMQP 1.0 always uses SASL). The broker validates the token's signature against Keycloak's JWKS, checks
that its audience is rabbitmq, and the scopes inside determine which exchanges and queues the client can touch.
The scope claim carries friendly aliases (jobs_write, results_read) that the broker's OAuth2 plugin maps to its
native permission format — including the routing key: jobs_write expands to rabbitmq.write:*/jobs/job.*, so the
dispatcher may publish to the jobs exchange but only with job.* routing keys. Keycloak stays readable; RabbitMQ
stays expressive. See Routing keys for the full convention.
Two details make the validation real:
- Audience — every token carries
aud: rabbitmq(added by a Keycloak audience mapper), because the broker'sverify_audis on by default. - TLS for JWKS — the OAuth2 plugin requires an
httpsJWKS URL. Rather than putting a certificate on Keycloak (which runs plain HTTP), the broker fetches the keys through Traefik, which terminates TLS with its built-in self-signed certificate. Nothing is committed; the broker usesverify_nonefor that internal hop.
Each service wires up the AMQP 1.0 stack by hand — there is no Spring Boot auto-configuration for AMQP 1.0, so the
Environment (with its OAuth2 settings), the SingleAmqpConnectionFactory, the RabbitAmqpTemplate, and the
RabbitAmqpListenerContainerFactory are all @Beans in an AmqpConfiguration. The client mints the token, caches it,
and — crucially — renews it on the live connection before it expires (see
Token expiry and in-place refresh).
The legacy 0.9.1
RabbitTemplateandChannelclasses are still on the classpath (pulled in transitively byspring-rabbitmq-client→spring-rabbit), but Spring Boot 4 movedRabbitAutoConfigurationinto the separatespring-boot-amqpmodule — which this project deliberately does not depend on. That's what keeps Boot from auto-creating a stray legacyCachingConnectionFactory/rabbitTemplate(pointed atguest/guest) that would clash with the AMQP 1.0 beans. Addingspring-boot-starter-amqpwould silently reintroduce it.
You need Docker and mise. mise handles Java, Maven, and Node versions automatically, so there's no manual setup.
git clone https://github.com/lukas-grigis/spring-oauth2-amqp-1.0.git
cd spring-oauth2-amqp-1.0
mise run demoOne command. It builds the Maven project, starts the infrastructure (Traefik, Postgres, Keycloak, RabbitMQ), provisions the Keycloak realm, then boots all three Spring Boot services. First run takes a minute while containers download.
When it's up, submit a job:
curl -X POST http://localhost:8080/jobs \
-H 'Content-Type: application/json' \
-d '{"payload":"hello amqp 1.0"}'Then watch the logs:
tail -f .logs/{dispatcher,worker,reporter}.logYou'll see the job flow through: dispatcher publishes → worker consumes from jobs.in and publishes the result →
reporter consumes from results.out and logs it.
Press Ctrl+C in the mise run demo terminal to stop everything.
The point of the demo is to prove that broker-level authorization actually holds. The reporter service (scope:
results_read) can consume results but the moment it tries to publish, RabbitMQ refuses — over AMQP 1.0 the broker
ends the session with the OASIS-standard condition amqp:unauthorized-access. The rejection comes from the broker, not
from any if (user.hasPermission(...)) in application code.
You don't have to take that on faith — the integration tests prove it against the live stack: every service may publish exactly what its scopes allow, and the reporter (publish), the dispatcher (cross-exchange), and the worker (out-of-scope routing key) are each refused.
Both exchanges are topic exchanges, and the routing key is part of the contract — and part of the authorization.
The convention is <domain>.<entity>.<event>, lowercase and dot-delimited, most-stable segment first:
jobs (topic) dispatcher publishes job.submitted → queue jobs.in binds job.#
results (topic) worker publishes result.ready → queue results.out binds result.#
Producers always publish a fully-qualified key (job.submitted); consumers bind with wildcards (# = zero or
more words, * = exactly one). Because each exchange owns its own key namespace (job.* vs result.*), the two stay
visibly distinct, and the OAuth2 write scopes carry the routing key (rabbitmq.write:*/jobs/job.*) — so the broker
enforces not just which exchange a service may publish to, but which routing keys.
Over AMQP 1.0 there are no channels and no basic.publish; a producer attaches a link to a target address and a
consumer attaches to a source address. RabbitMQ's v2 address format
maps cleanly onto the same exchange/routing-key/queue model:
publish job.submitted to jobs → target /exchanges/jobs/job.submitted
consume from jobs.in → source /queues/jobs.in
Spring's RabbitAmqpTemplate and @RabbitListener take the familiar exchange / routingKey / queue arguments and
build those v2 addresses for you — so the application code reads exactly like the 0.9.1 version.
When to reach for what:
| Change | Add it when… |
|---|---|
| A new routing key | there's a new event type a subset of consumers cares about (job.failed) |
| A new binding | a new consumer wants a subset of existing events (a jobs.audit queue, job.#) |
| A new exchange | you cross a domain boundary (jobs vs. results) |
The integration test exercises this directly: the worker may publish result.ready but is refused when it tries
internal.audit, because that key falls outside its result.* scope. (Note the AMQP 1.0 nuance: the exchange-level
write check happens when the link attaches, while the routing-key check happens on the first message transfer — so
the worker's link to results attaches fine and is refused only when it sends internal.audit.)
support/rabbitmq/rabbitmq.conf enables the OAuth2 plugin alongside the internal backend (kept so the management UI
stays usable with admin/admin). This file is identical to the AMQP 0.9.1 version — OAuth2 authentication and
authorization are protocol-agnostic, and AMQP 1.0 needs no plugin (it is native in RabbitMQ 4.x):
auth_backends.1=rabbit_auth_backend_internal
auth_backends.2=rabbit_auth_backend_oauth2
auth_oauth2.resource_server_id=rabbitmq
# The plugin requires an https JWKS URL. Keycloak runs plain HTTP, so the broker fetches the keys through
# Traefik, which terminates TLS with its built-in self-signed cert (verify_none for this internal hop).
auth_oauth2.jwks_uri=https://traefik/auth/realms/amqp-demo/protocol/openid-connect/certs
auth_oauth2.https.peer_verification=verify_none
auth_oauth2.https.hostname_verification=none
# Aliases map clean Keycloak scope names to native permissions. The 3rd segment on write scopes restricts
# the routing key (glob match — "job.*" = routing keys starting with "job.").
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.outThe scope aliases keep Keycloak scope names clean (jobs_write) while the broker operates in its native permission
vocabulary (rabbitmq.{permission}:{vhost}/{resource}/{routing-key}).
There is no Spring Security OAuth2AuthorizedClientManager here. The RabbitMQ AMQP 1.0 client has first-class OAuth2
support, so token acquisition lives on the Environment's connection settings — Boot owns nothing of the AMQP stack,
because there is no AMQP 1.0 auto-configuration to begin with:
Environment environment = new AmqpEnvironmentBuilder()
.connectionSettings()
.host("localhost").port(5672).virtualHost("/")
.oauth2()
.tokenEndpointUri("http://localhost/auth/realms/amqp-demo/protocol/openid-connect/token")
.clientId("worker").clientSecret("worker-secret")
.grantType("client_credentials")
.parameter("scope", "jobs_read results_write")
.shared(true) // one token, refreshed once, shared by all connections
.connection()
.environmentBuilder()
.build();(Simplified — the real AmqpConfiguration binds all of this from validated app.amqp properties.) The client fetches
the token over plain HTTP, and its token requester pins the JDK HTTP client to HTTP/1.1 — so it sidesteps the
cleartext-h2c-through-Traefik deadlock that the 0.9.1 version had to work around with Apache HttpClient 5. The token
becomes the AMQP SASL password; the broker validates it on connect and on every operation.
This is the one place where AMQP 1.0 genuinely differs from 0.9.1, and the demo leans into it.
- On AMQP 0.9.1, an expired token does not drop the connection — the broker simply refuses every subsequent
operation with
ACCESS_REFUSED, and the 0.9.1 demo renews the token in place with theupdate-secretmethod (driven by aCredentialsRefreshService). - On AMQP 1.0, when the token on a live connection expires, the broker disconnects the client. So a long-lived
connection must refresh proactively. The renewal mechanism is also different: the client sends an HTTP-over-AMQP 1.0
PUT /auth/tokensrequest over the existing connection — there is noupdate-secretin AMQP 1.0.
The good news: configuring oauth2() is all you need. The RabbitMQ AMQP 1.0 client does the whole dance
automatically — it acquires the token, schedules a refresh at ~80% of the token's lifetime (the same ratio the
0.9.1 demo used), and re-authenticates the live connection via PUT /auth/tokens. No reconnect, no dropped messages,
and no application code. The explicit CredentialsProvider + CredentialsRefreshService wiring from the 0.9.1 version
is simply gone, replaced by one oauth2() block.
The integration tests prove the broker half of this story directly: with the realm's token lifespan shortened to 15s, a live AMQP 1.0 connection is disconnected once its token expires — which is exactly what the services' in-place refresh exists to prevent.
Keycloak runs start-dev over plain HTTP behind Traefik (at /auth) — no certificate on Keycloak itself. Two small
consequences, both handled automatically by mise run demo / mise run keycloak:setup:
- Keycloak is backed by Postgres (not the in-memory H2) so the next point survives a restart.
- The
masterrealm refuses plain-HTTP admin calls by default. The setup flipsssl_requiredtoNONEonmaster(UPDATE realm SET ssl_required = 'NONE'viapsql, then a restart — dev only) so the Admin-API provisioner can run over HTTP. Theamqp-demorealm is created withsslRequired: nonedirectly.
# 1. start everything (separate terminal, leave it running)
mise run demo
# 2. run the integration tests against the live stack
mise run test:integration- Per-service context tests (
*ApplicationTest, run bymise run test/mvn clean verify) boot each Spring context offline — they verify the beans and the validatedapp.amqpproperties wire up. No broker is required: the AMQP 1.0Environment, connection factory, and listener containers all connect lazily. - Integration tests (
test/, Node's built-in test runner +rhea, the AMQP 1.0 client for Node) run against the live stack and are the real proof. They fetch realclient_credentialstokens for each service and assert: the HTTP entry point accepts a job; every service may publish what its scopes allow; and the reporter (publish), the dispatcher (cross-exchange), and the worker (out-of-scope routing key) are each refused by the broker withamqp:unauthorized-access. A final test shortens the realm's token lifespan to 15s and proves the AMQP 1.0 lifecycle: the broker disconnects a live connection once its token expires.
mise run infra:up # start Traefik + Postgres + Keycloak + RabbitMQ
mise run keycloak:setup # relax master-realm SSL, then provision realm + clients + scopes
mise run dispatcher:start # start just the dispatcher
mise run worker:start
mise run reporter:start
mise run infra:down # tear everything down.
├── pom.xml # parent Maven module (aggregates the three services)
├── mise.toml # tool versions + the `mise run …` task runner
├── dispatcher/ # REST trigger → publishes jobs
├── worker/ # consumes jobs, publishes results
├── reporter/ # consumes results (read-only)
├── test/ # Node + rhea integration tests against the live stack
└── support/
├── .env # ports + image tags
├── docker-compose.yml
├── database/ # Postgres .env + init script (creates the keycloak DB)
├── keycloak/ # realm + client provisioning (TypeScript Admin API)
└── rabbitmq/ # rabbitmq.conf, enabled_plugins, topology definitions
The database and keycloak services each own a small .env file that docker-compose loads via env_file:, so their
configuration lives next to them. (support/.env holds the shared host ports and image tags.)
If you've read the 0.9.1 version, here's the full diff in one table. The broker, the realm, and the topology are untouched; everything that changed is on the client side.
| Area | AMQP 0.9.1 | AMQP 1.0 (this repo) |
|---|---|---|
| Client library | spring-boot-starter-amqp (RabbitMQ Java client 5.x) |
spring-rabbitmq-client 4.0.3 (RabbitMQ AMQP 1.0 client) — wired by hand, no auto-config |
| Send / receive API | RabbitTemplate, @RabbitListener (channels) |
RabbitAmqpTemplate, @RabbitListener via RabbitAmqpListenerContainerFactory (links) |
| Addressing | exchange + routing key on a channel | v2 target/source addresses (/exchanges/jobs/job.*, /queues/jobs.in) — Spring builds them |
| Token acquisition | Spring Security OAuth2AuthorizedClientManager + CredentialsProvider |
the AMQP client's own oauth2() settings (no Spring Security in the broker path) |
| Token refresh | CredentialsRefreshService → update-secret at 80% |
built into the client → PUT /auth/tokens at ~80% (automatic) |
| Behavior on expiry | broker refuses operations, connection stays open | broker disconnects the connection |
| Authorization failure | channel 403 ACCESS_REFUSED |
session/connection amqp:unauthorized-access |
| HTTP token client | needed Apache HttpClient 5 (JDK HttpClient deadlocked on h2c) | client's token requester is pinned to HTTP/1.1 — no workaround needed |
| Integration tests | amqplib |
rhea |
| Broker / realm / topology | — | unchanged (rabbitmq.conf, definitions.json, Keycloak all identical; RabbitMQ 4.1 → 4.2 for live-connection refresh) |
| Layer | What's running |
|---|---|
| Services | Spring Boot 4, Spring AMQP 1.0 client (spring-rabbitmq-client) |
| Broker | RabbitMQ 4.2 — native AMQP 1.0 + rabbitmq_auth_backend_oauth2 |
| Auth | Keycloak 26 (client_credentials grant) + Postgres |
| Edge | Traefik (reverse proxy, TLS termination for JWKS) |
| Infrastructure | Docker Compose |
| Build | Maven (multi-module), Java 25 |
This is a demo. The setup is optimized for clarity, not for production:
- Client secrets are committed in plain text (use a secrets manager in real life; the apps already read them from
${*_CLIENT_SECRET}env vars, defaulting to the demo values) - Keycloak runs in
start-devover plain HTTP, withssl_requireddisabled on themasterrealm; services fetch tokens over plain HTTP, and the broker reaches its JWKS through Traefik withverify_none(use real CA-signed certs andverify_peerend-to-end — and HTTPS token endpoints withoauth2().tls()— in production) - The Postgres and RabbitMQ admin users are
admin/admin - The
dispatcherREST endpoint is unauthenticated
Don't deploy this as-is. Use it as a reference for how broker-level OAuth2 wires together over AMQP 1.0, then harden it to your own standards.
If you find a bug or have an idea, open an issue or send a pull request.
- Fork the repo
- Create a branch (
git checkout -b my-change) - Make your changes
- Run
mise run test(andmise run test:integrationagainst a running stack) to make sure things work - Open a PR