Skip to main content
Level 2
October 16, 2026
New

A secret slot pattern for runtime-configurable REST integrations on AEMaaCS

Applies to: AEM as a Cloud Service (author and publish)

I ran into this while building a generic REST connector. You import an OpenAPI spec, authors set up a connector in /conf, and the data ends up in Content Fragments. The data side was the easy part. Credentials were not. Every API needs a secret, and on AEMaaCS a secret only reaches your code through OSGi config that ships with a deployment. So "an author adds a new API" quietly becomes "a developer ships a release", which defeats the point of a generic connector.

This is the idea I came up with for that, and I'd like to know what you think of it.

A quick word on where it stands. It's my own design on top of what Adobe documents, not an Adobe recommendation. I've compiled and unit tested the service class on a plain JDK, but I haven't run any of it on a real AEMaaCS environment. Adobe documents the local setup for placeholders (it uses the Felix configadmin plugin) but not how the cloud runtime resolves them, so wherever I lean on the plugin's behavior I'm going by its README and source. Worth checking on a dev environment before you trust it.

The idea

Instead of one secret per integration, deploy a pool of numbered slots once: REST_SECRET_01, REST_SECRET_02 and so on. Each slot is a $[secret:...] placeholder in an OSGi config, sitting next to the hosts it's allowed to be sent to. The connector config in /conf only stores the slot name, for example "secretRef": "REST_SECRET_01". A small OSGi service looks the slot up when you make the call, and it only hands back the value if the URL points at one of that slot's hosts.

For a host that's already declared, an author then needs a Cloud Manager variable and a connector config, with no code release. A brand-new host still needs a config change in Git. I'm fine with that, because the host list is the thing that stops a credential going somewhere it shouldn't.

Declaring the slots

This goes in your ui.config module. I only declare slots under config.author, since the sync jobs run there and publish is the internet-facing tier. Name the file after the component's class, so in my case com.example.restconnector.core.secrets.SecretSlotService.cfg.json:

{
"REST_SECRET_01": "$[secret:REST_SECRET_01]",
"REST_SECRET_01.hosts": ["api.example-weather.com"],

"REST_SECRET_02": "$[secret:REST_SECRET_02]",
"REST_SECRET_02.hosts": ["auth.example-stores.com"],

"REST_SECRET_03": "$[secret:REST_SECRET_03]",
"REST_SECRET_03.hosts": ["auth.example-stores.com"]
}

Why hosts per slot and not one global allowlist? With a single list, an author editing a connector could pair a token meant for API A with API B's host and send it to the wrong place. Per-slot hosts close that gap. They don't help against someone who can deploy code, since code can read any slot.

Two things to keep in mind. Preview takes its OSGi config from publish (there's no config.preview folder), and config is resolved per PID from the most specific runmode folder, so you can't spread one PID across config.author and config.author.dev. Also, I set configurationPolicy = REQUIRE, which means the service doesn't exist on a tier with no config for it. The OSGi spec says a required configuration has to be present for the component to be satisfied. Anything on publish that references the service needs an optional reference.

The service

It's one DS component. It keeps an immutable map of slots, swaps in a new one whenever the config changes, and its activate method takes a Map<String, Object>, the same as the secrets manager in Adobe's tutorial. getSecret() is the only method that returns a value, and this is the check that decides:

if (!"https".equalsIgnoreCase(target.getScheme()) || target.getUserInfo() != null) {
return Optional.empty();
}
if (target.getPort() != -1 && target.getPort() != 443) {
return Optional.empty();
}
String host = normalizeHost(target.getHost());
if (host == null || !slot.hosts.contains(host)) {
return Optional.empty();
}
return Optional.of(slot.value);

A slot counts as unset if the value is missing, empty, still the literal placeholder, or has a line break in it, so isSet() and getSecret() can never disagree. Host names get normalized on both sides (lowercase, punycode, no trailing dot) and have to be plain names, so no wildcards, paths, schemes, ports, underscores or IP literals. When an entry gets dropped, or a slot has a value but no usable host, it logs a warning, so a typo doesn't fail silently. A Unicode host in the URL won't match, so use the punycode form, because java.net.URI gives no host for non-ASCII names. It never logs a value. isDeclared() and isSet() are there for a status page.

You need Java 10 or later because of Map.copyOf(). Cloud Manager builds with JDK 8 unless you set .cloudmanager/java-version, and Adobe's build page says that file should contain only 21 or 17.

Using it

Build one URI, check the secret against it, and pass that same URI to the client, so what you checked is exactly what you call:

URI target = URI.create(url);
String token = secretSlotService.getSecret(connector.getSecretRef(), target)
.orElseThrow(() -> new ConnectorException(
"Secret slot " + connector.getSecretRef() + " is unavailable for this request"));

HttpGet request = new HttpGet(target);
request.setHeader("Authorization", "Bearer " + token);

I build the HttpClient once on activate and close it on deactivate, with redirects off so a redirect can't carry the header to another host:

client = HttpClientBuilder.create()
.useSystemProperties()
.setDefaultRequestConfig(RequestConfig.custom()
.setRedirectsEnabled(false)
.setConnectTimeout(1000)
.setSocketTimeout(5000)
.setConnectionRequestTimeout(1000)
.build())
.build();

The 1 second connect and 5 second read timeouts are what Adobe's development guidelines suggest. useSystemProperties() matters if you use a dedicated egress IP or VPN, because Apache HttpClient needs it to use the proxy. Whatever you do, keep the secret, the headers and any URL with credentials out of error messages and logs, and don't turn off TLS hostname verification.

If an API needs two values, like a client ID and a secret, use one slot per value. Those slots only list the token host, and the access token you get back stays in memory.

Then in Cloud Manager, create a secret variable named exactly like the slot, pick the service it applies to, and paste the value without leading or trailing spaces. Save once for all the variables you're changing.

What to watch out for

Adobe's docs don't quite agree on how a change is applied. The OSGi page says calling the API deploys new variables like a code deployment, and that author and publish restart, typically within a few minutes, without the quality gates. The environment variables page says changes take effect without code changes or deployments, and shows an Updating status. A KB article about preview-scoped variables mentions only Preview restarting, and nothing says what an author-scoped variable does. I'd test it on dev and schedule changes outside editing hours. If a variable sits on Updating for more than 10 to 15 minutes, KA-36282 says to look for stray whitespace in the name or value.

On limits: names are 2 to 100 characters matching [a-zA-Z_][a-zA-Z_0-9]*, and INTERNAL_, ADOBE_ and CONST_ are reserved. Values can be 2048 characters, but the API reference says secretString values must be under 500. The number of variables per environment is 200 on the OSGi page (the 2020.7.0 release notes say it was raised to 200) and 400 on the environment variables page. I plan for 200. A pool of 20 slots uses 40 if each slot is set separately for author and publish.

If no value is set, Adobe says the placeholder stays in place at runtime, so your code sees the literal $[secret:...] text. That's why the service treats it as unset. Placeholders only work in OSGi config. According to the Felix plugin README, a DS component gets resolved values, but code that reads ConfigurationAdmin properties directly sees the raw placeholders.

For outbound calls, ports 80 and 443 are open by default. Other HTTP(S) ports need one of the advanced networking options and the documented proxy, and that page also recommends connection pooling.

Anyone who can open the OSGi Components view in the Developer Console may see the injected values. Adobe doesn't say whether secret variables are masked there, so I'd assume they aren't, and I'd limit who has console access. To check whether a slot has a value, use your own isSet() status page rather than the console.

This isn't meant for OAuth refresh tokens or anything else that changes at runtime, for credentials of around 500 characters or more (PEM keys, say), for big pools, or for changes that must take effect instantly, since every secret change is an environment update.

A few habits I'd keep. Never log values, the activation property map or request headers. Restrict write access to connector configs under /conf, because anyone who can edit one can choose any slot. Use separate secrets per environment. Rotate by changing the value in Cloud Manager, which Adobe's tutorial says needs no redeployment, but values aren't versioned, so a code rollback can pick up a newer value. For risky changes, put the new value in a spare slot and repoint the reference. And when you replace a placeholder with a hardcoded value, delete the old variable too, because KA-27437 says AEM keeps preferring the existing secret until you do.

Testing locally

Add this to the end of crx-quickstart/conf/sling.properties and restart:

org.apache.felix.configadmin.plugin.interpolation.secretsdir=${sling.home}/secretsdir

Then create one file per secret in that folder, named exactly like the variable, with no extension. Adobe's OSGi page says all the secret files go in the same directory. Write each file without a trailing newline, for example printf '%s' 'value' > secretsdir/REST_SECRET_01. The plugin source trims whitespace, but its README describes the file contents as taken as-is, and a trailing newline would make the slot count as unset in this service.

A Tools page for empty slots (idea for later)

When several sites each keep their own connector configs, people need a way to see which configurations are still waiting for a value. I'm thinking of a small AEM tool, maybe under Tools > REST connector > Secret slot status, that lists each connector configuration, shows whether its slots are set, empty, unknown or missing, and names the Cloud Manager variable to create. It would only use isDeclared() and isSet(), so it never sees a secret value. That's just an idea for now, and I'll add details to this post later.

That's where I am. If you've solved this a different way, or you can see a hole in the host binding, I'd like to hear about it.

Docs I used