Security
Four separate things, often confused with each other:
| Transport security | TLS on the connection to the broker. Properties |
| Credentials | Who the application connects as, and where the secret comes from |
| Payload encryption | The message body, encrypted so the broker cannot read it. A
Codec bean |
| Exposure | What the starter puts on an actuator endpoint, and what to do about it |
TLS protects the message in transit and stops at the broker. Payload encryption protects it from the broker as well, and from anybody reading a backup of it. They are not alternatives: an application handling card data usually wants both.
TLS
The scheme in the URL asks for TLS; acemq.tls.mode says
how strictly.
acemq:
url: amqps://broker.internal:5671
tls:
mode: required
keystore: /etc/acemq/certs
keystore-password: ${KEYSTORE_PASSWORD}| Property | Default | What it does |
|---|---|---|
acemq.tls.mode |
disabled |
disabled, required or
insecure |
acemq.tls.keystore |
— | Directory holding keystore.p12 and
truststore.p12 |
acemq.tls.keystore-password |
— | Password for both stores |
acemq.tls.allow-development-certificates |
false |
Accept certificates carrying AceMQ's development marker |
required verifies the certificate chain and the
hostname. With no keystore it verifies against the JVM's
own trust store, which is the right answer for a broker with a
certificate from a public authority. With a keystore it
verifies against the truststore in that directory and presents the
client certificate from the keystore, which is the right answer for an
internal certificate authority and for mutual TLS.
insecure verifies neither the chain nor the hostname. It
exists so that a development broker with a self-signed certificate is
one word rather than an afternoon, and it should never reach an
environment where the broker holds anything real.
There is no verify-hostname: false.
Disabling verification is spelled insecure, and that is
deliberate. A boolean in a properties file reads exactly like the lines
around it, and the one that turned verification off for an afternoon in
2024 is still there. A word that says what it is survives a code
review.
mode: disabled against an amqps:// URL does
not quietly downgrade to plaintext — the connection fails. A URL that
asks for TLS and a mode that refuses it are a contradiction, and
guessing which half was meant is how an application ends up sending
credentials in the clear while its configuration says
amqps.
Development certificates
A self-signed certificate that works in development is also a self-signed certificate that works in production, which is the problem. The library's answer is a marker: certificates generated for development carry the string
ACEMQ DEVELOPMENT ONLY - DO NOT TRUST
in their subject, and mode: required refuses them unless
allow-development-certificates: true is also set.
That option cannot silently weaken a real deployment. A certificate from a real authority does not carry the marker, so the option only ever accepts the certificates that announce themselves as untrustworthy. Leaving it set in a production profile is harmless in the sense that it changes nothing; it is still worth not doing, because the next person reads it as a statement about the environment.
Generating a set is a library concern rather than a starter one:
DevelopmentCertificates.Result certs = new DevelopmentCertificates()
.generate(Path.of("target/certs"), "localhost", "acemq-dev".toCharArray(), Duration.ofDays(30));
certs.directory(); // holds keystore.p12 and truststore.p12
certs.expiry();acemq-security-dev also carries a Maven mojo, so the
same thing can be bound to a build phase rather than written into a test
fixture. And
new DevelopmentCertificates().rabbitMqConfiguration(pathInsideTheContainer)
returns the rabbitmq.conf fragment that makes a broker
present the certificate it just generated — which is what makes a
Testcontainers broker speak amqps without a checked-in
certificate. See Testing.
The default password, acemq-dev, is a constant in the
library. It is not a secret and is not pretending to be one.
A development profile
# application-local.yml
acemq:
url: amqps://localhost:5671
tls:
mode: required
keystore: target/certs
keystore-password: acemq-dev
allow-development-certificates: truerequired rather than insecure, even
locally. Verifying against a real truststore in development is what
makes a certificate problem show up on a laptop instead of in a
deployment, and the development marker is what keeps this file from
being a template somebody copies into staging.
Credentials
acemq:
username: ${BROKER_USER}
password: ${BROKER_PASSWORD}Ordinary Spring property resolution, so everything Boot can do
applies: environment variables, a SPRING_CONFIG_IMPORT of a
mounted file, Spring Cloud Vault, a ConfigDataLoader of
your own. The starter reads two strings and does not care where Boot
found them.
Credentials in the URL survive. Setting neither
username nor password leaves whatever is in
amqp://user:pass@host alone. The mapping is tested for it,
because the natural implementation passes two nulls through and quietly
connects as guest — and the failure that produces is an
authentication error against a broker whose configuration is
correct.
Do not put a password in application.yml. Boot's
${...} costs nothing, and a password in a properties file
is a password in the image, in the build cache and in whatever scrapes
the repository.
Credentials that change while the application is running
A broker credential fetched from Vault with a lease, or a short-lived
token, changes after the connection is made. acemq.username
and acemq.password are read once, when the
ConnectionConfig bean is built, so they cannot express
that.
The library can:
Security.withCredentials(CredentialsProvider) is asked each
time it connects. The starter does not map a property onto it — there is
no sensible property for "call this code" — so reach it by defining the
ConnectionConfig bean yourself. Every bean in the
auto-configuration backs off when the application defines its own, and
this one is the intended seam:
@Configuration
class BrokerConnection {
/**
* Replaces the auto-configured ConnectionConfig. Everything downstream — the AceMq bean,
* the listeners, health — is built from this one.
*/
@Bean
ConnectionConfig aceMqConnectionConfig(
AceMqProperties properties, Environment environment, VaultOperations vault) {
Security security = Security.fromKeystore(properties.getTls().getKeystore())
.keystorePassword(properties.getTls().getKeystorePassword())
.withCredentials(() -> {
VaultResponse lease = vault.read("rabbitmq/creds/orders-service");
return Credentials.of(
(String) lease.getRequiredData().get("username"),
(String) lease.getRequiredData().get("password"));
});
// The auto-configuration fills the client name in from spring.application.name when
// acemq.client-name is unset. That code is not running any more, so do it here —
// otherwise every connection in the broker's UI is called "acemq".
String clientName = properties.getClientName() != null
? properties.getClientName()
: environment.getProperty("spring.application.name", "spring-boot");
return ConnectionConfig.url(properties.getUrl())
.clientName(clientName)
.connectionTimeout(properties.getConnectionTimeout())
.confirmTimeout(properties.getConfirmTimeout())
.blockedTimeout(properties.getBlockedTimeout())
.maxOutstandingPublishes(properties.getMaxOutstandingPublishes())
.security(security)
.build();
}
}Note what this costs, because it is the general cost of overriding an
auto-configured bean: this method now owns the whole mapping. Neither
acemq.publisher-confirms nor
acemq.virtual-host is read above, so setting them in the
file would now do nothing, and a property added to
AceMqProperties in a later version is not picked up until
this method is updated. Take AceMqProperties as a
parameter, as here, and copy across every one you care about rather than
hard-coding values — that way the file still configures the connection
and only the credentials are code.
AceMqConnections.from(properties) is the starter's own
mapping and is public; read it to see what a complete copy looks
like.
CredentialsProvider has three ready-made forms for the
cases that are not Vault:
CredentialsProvider.of("orders-service", secret);
CredentialsProvider.fromEnvironment("BROKER_USER", "BROKER_PASSWORD");
CredentialsProvider.fromFile(Path.of("/var/run/secrets/broker"));fromFile re-reads the file, so a Kubernetes secret
remounted with a new value is picked up on the next connection without a
restart. Credentials.token(token) is the shape for a broker
using a token rather than a username and password.
Payload encryption
TLS ends at the broker. A message on a queue is plaintext on the broker's disk, in its management UI, and in whatever backs it up. For a payload holding card data, medical data or anything a regulator has an opinion about, that is often not good enough.
EncryptedCodec wraps another codec: the payload is
serialised as usual, then encrypted, and the result is a body the broker
cannot read and a content type that says so.
The starter's codec is a bean with
@ConditionalOnMissingBean, so defining your own replaces it
— and everything built from it, publishers and listeners alike, uses
it:
@Configuration
class Encryption {
@Bean
Codec aceMqCodec(AceMqProperties properties, Keyring keyring) {
return EncryptedCodec.wrapping(Codecs.byName(properties.getFormat()), keyring);
}
/**
* Two keys: the one messages are written with, and the one they used to be written with.
* Both are needed for as long as a message written under the old one might still be on a
* queue — which, for a queue with a week's retention, is a week.
*/
@Bean
Keyring keyring(
@Value("${payments.keys.current}") String current,
@Value("${payments.keys.previous}") String previous) {
return Keyring.builder()
.add("payments-2026-06", Keys.fromBase64(previous))
.current("payments-2026-09", Keys.fromBase64(current))
.build();
}
}Keyring.of(id, key) is the single-key form, for an
application that has not rotated yet.
Rotation is the reason the keyring is a keyring.
Each encrypted body records the id of the key that wrote it, so a
consumer holding both keys reads old and new messages without knowing
which is which, while every new message is written with
current. Retire a key only once nothing on any queue can
still be holding a message written under it; a body whose key id is no
longer in the ring fails to decode, loudly, rather than being handled as
something else.
EncryptedCodec.keyIdOf(body); // which key wrote this, without decrypting it
EncryptedCodec.CONTENT_TYPE; // application/vnd.acemq.encryptedkeyIdOf is what a triage tool uses on a message in a
dead-letter queue: it answers "can this consumer even read it" before
anyone tries.
Keys themselves: Keys.generate() for a new one,
Keys.fromBase64 / Keys.toBase64 to move one in
and out of a secret store, Keys.fromBytes for a KMS that
hands back raw material. The key is a
javax.crypto.SecretKey and the starter never sees it — it
goes into the Keyring bean and stays there.
A @Value on a base64 key is the example, not the
recommendation. It is short enough to read. In a real
deployment the two @Values are a Vault lookup or a KMS
decrypt, for the same reason acemq.password should not be
in the file.
Encrypting only some messages
Replacing the Codec bean encrypts everything on the
connection. When only one message type needs it, put the codec on the
publisher and the consumer instead of on the connection:
@Bean(destroyMethod = "close")
Publisher<Payment> paymentPublisher(AceMq mq, Codec encrypting) {
return mq.publisher("payments", "payment.taken", Payment.class).as(encrypting);
}
@Bean(destroyMethod = "close")
ConsumerGroup payments(AceMq mq, Codec encrypting, PaymentHandler handler) {
return mq.consumeGroup("payments.new", Payment.class, handler::handle)
.options(ConsumerOptions.defaults().as(encrypting))
.start();
}@AceListener has no codec attribute, so an annotated
listener always uses the connection's codec. A queue whose messages are
encrypted and whose consumer is an annotation therefore needs the
connection-wide form above — or the consumer declared as a bean, as
here.
The encrypted content type is deliberately not something the plain JSON codec claims it can decode, so a consumer configured with the wrong codec fails at the first message with a clear error rather than handing a handler nonsense.
What the application exposes
The health indicator reports the broker URL's transport, whether the connection is open, whether it is blocked and why, and how many publishes are in flight. None of that is a credential, and the URL's user info is not among it — but "which broker, in which state" is more than an unauthenticated endpoint should say about your infrastructure.
Actuator's own settings decide who sees it:
management:
endpoints:
web:
exposure:
include: health
endpoint:
health:
show-details: when-authorized
show-components: when-authorizedshow-details: always on a publicly reachable actuator is
the mistake worth naming. The starter does not choose for you, because
Actuator already has these properties and a starter overriding them
would be a second place to look.
A replay endpoint is the other
one. It republishes production traffic and belongs behind an
authenticated role, not behind include: "*".
Reporting a problem
Security issues in this starter go to the address in SECURITY.md, not to the issue tracker.