Back to Blog

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.

19 min read
oauth2rabbitmqkeycloakspring-bootspring-amqpjavazero-trustmicroservices
A Spring app secured across surfaces: Keycloak issues tokens, the services are OAuth2 clients, and RabbitMQ is the resource server enforcing scopes

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 tokenssame 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 authoritiesscopes → publish/consume permissions (even per routing key)
Token rides in the Authorization: Bearer headertoken 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.

The standard OAuth2 triangle (identity provider, clients, resource server), with RabbitMQ standing in for the REST API.

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 demo
bash|run the whole demo

Then 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"}
bash|trigger one job

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",
    },
  }
)
typescript|support/keycloak/setup-keycloak.tsSource

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:

ServiceKeycloak scopesCan do
dispatcherjobs_writepublish jobs
workerjobs_read, results_writeconsume jobs, publish results
reporterresults_readconsume 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/certs
ini|support/rabbitmq/rabbitmq.confSource

On 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.out
ini|support/rabbitmq/rabbitmq.conf: scope_aliasesSource

Decode 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/token
yaml|dispatcher/src/main/resources/application.ymlSource

The 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); 
java|dispatcher/.../security/OAuth2ClientConfiguration.javaSource

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>
xml|pom.xmlSource

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(); 
  }
}
java|dispatcher/.../security/OAuth2CredentialsProvider.javaSource

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
  };
}
java|dispatcher/.../configuration/messaging/AmqpConfiguration.javaSource

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 job that is allowed end to end, above the three publishes the broker refuses.

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: "/",
})
typescript|test/test.tsSource

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) 
})
typescript|test/test.ts: the refusalsSource

Three refusals, each a different shape of least privilege:

  • The reporter (results_read) tries to publish result.ready and is refused. It has no write scope anywhere.
  • The dispatcher (jobs_write) tries to publish to the results exchange and is refused. Its write scope grants the jobs exchange only, so it cannot cross over.
  • The worker (results_write) tries to publish internal.audit to results and is refused. Its write scope is pinned to the result.* routing key, and internal.audit falls outside it.
Least privilege, enforced at the broker. Green is allowed, red is ACCESS_REFUSED (403).

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"
text|broker log: before the fix (repeating on every publish)

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()); 
}
java|dispatcher/.../security/OAuth2CredentialsProvider.javaSource

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); 
java|dispatcher/.../configuration/messaging/AmqpConfiguration.javaSource

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 client
text|broker log: after the fix (every ~48s)

There 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}`)
typescript|test/test.ts: update-secret renews on a live connectionSource

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

Share this article

Enjoyed this article?

Stay in the Loop

Get notified when I publish new articles. No spam, unsubscribe anytime.

More Posts