msgyard /aoptoken
RabbitMQ authentication plugin · Erlang

Password or token.
Same account. No client changes.

A RabbitMQ auth backend that accepts a pre-issued JWT — the opaque token:<jwt> style used by Pulsar/AoP brokers — right alongside ordinary username/password auth, for the very same user. Built for teams migrating off a Pulsar-based broker onto native RabbitMQ without touching a single client.

Verified RabbitMQ 3.11–4.3 Tests 45 EUnit, in CI Algorithms HS · RS · ES OTP 25–29 License MPL-2.0
The problem

One account can only hold one secret

RabbitMQ's internal backend stores a single password hash per user. After migrating from a Pulsar/AoP broker you have two kinds of clients on the same account — some send a password, some send a bearer token — and the built-in OAuth2 backend rejects the minimal {"sub":"…"} tokens these brokers issue. So the usual advice is "pick one and change the others." You can't always change the others.

internal only breaks token clients

Stored hash matches the password, so the token client — sending token:eyJ… — never matches and is refused. Or the reverse. One side always loses.

with this backend both connect

Password clients authenticate against internal; token clients are verified by signature. Same username, same permissions, no change on the wire.

How it works

A backend chain, decided by fall-through

RabbitMQ natively supports chaining authentication backends. The client just sends a username and one secret in the AMQP password field — it never declares which mode it's using. The broker tries each backend in order until one accepts.

client sends → username + secret  (AMQP PLAIN — mode not declared)
1
rabbit_auth_backend_internal
Hashes the secret with the user's salt and compares to the stored password.
match → password
2
rabbit_auth_backend_aoptoken (this plugin)
Sees the token: prefix, verifies the JWT signature with the configured key (HS/RS/ES, per the token's alg), checks exp/nbf, and takes sub as the identity.
valid → token
↳
authorization → internal
Whichever backend authenticated, vhost and resource permissions come from the identity's existing internal grants.
delegated
✕
no backend accepted
Wrong password and no valid token signature.
refused
Configuration

One file, five lines

Enable the chain, then point the plugin at the issuer's signing key — both in the file you already edit. Authorization stays with internal, so your existing users, vhosts and permissions are untouched.

rabbitmq.conf
# try password first, then verify a token
auth_backends.1 = internal
auth_backends.2.authn = rabbit_auth_backend_aoptoken
auth_backends.2.authz = internal

# symmetric secret for HS256/384/512
auth_aoptoken.key_file = /etc/rabbitmq/token.key

# or a public key for RS*/ES*:
# auth_aoptoken.public_key_file = /etc/rabbitmq/token-public.pem

Settings can also go in advanced.config as Erlang terms, which wins where the two overlap. Prefer the _file forms for secrets — rabbitmq.conf tends to end up in configuration management and backups.

Settings and defaults

settingdefaultwhen not configured
key / key_base64 / key_filenoneHS* tokens refused, warning logged
public_key / public_key_base64 / public_key_filenoneRS* / ES* tokens refused, warning logged
audiencenonethe aud claim is not checked
leeway_seconds0no clock-skew tolerance for exp/nbf
accept_bare_jwtfalseonly token:-prefixed credentials are treated as tokens
emit_eventstrueset to false to stop publishing audit events

There is no built-in key and no fallback secret or path. With no key configured the plugin refuses every token and leaves the rest of the chain alone, so internal password authentication keeps working.

token — what the client already sends
% AMQP password field, unchanged:
token:eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJhcHAxIn0.<hs256-sig>
% payload decodes to:  {"sub":"app1"}
Details

What it does, precisely

HS · RS · ES

Verifies, doesn't allow-list

The algorithm comes from the token header: HMAC with a shared secret, or RSA/ECDSA with a public key — Pulsar's tokenSecretKey and tokenPublicKey modes. Any validly signed token works, including ones issued later. alg: none is rejected.

exp · nbf · aud

Claims are enforced

Expiry and not-before are honoured when present, with optional clock-skew leeway, and an expected audience can be required. Checked only after the signature verifies.

identity = sub

Token's subject is the identity

Authorization resolves against the sub claim, matching how Pulsar/AoP brokers behave — so a client whose login name differs from its token subject still lands on the right permissions.

authn only

Leaves authorization alone

The plugin authenticates; permissions stay in RabbitMQ's internal database. Import your definitions as usual — nothing about vhosts or grants changes.

key stays server-side

No secret in the build

The signing key is read from a file path at runtime and never embedded in the plugin. Rotate keys and tokens on your own policy.

access.auth.verified

Audit every token

Each verified token is published as an internal event: who authenticated, from where, prefixed or bare, and when the token expires. Read it through rabbitmq_event_exchange.

Getting started

Install

  1. Download the .ez from the latest release — pick the -otpNN matching your broker's Erlang major version — and drop it into plugins/. (Or build it yourself with the standard RabbitMQ plugin toolchain.)
  2. Enable it: rabbitmq-plugins enable rabbitmq_auth_backend_aoptoken
  3. Set the key and the auth chain in rabbitmq.conf (see above).
  4. Restart the node, then verify with both a password client and a token client on the same account.