| title | Management API |
|---|
/admin-api is the administrative console at /admin
over JSON, for a script, a CI job or a test rather than a person. Whatever the console shows
can be read here, and whatever it can change can be changed here. It is not a
protocol, but it is protected like one: every call carries an
OAuth 2.0 access token that this
service issued, in the RFC 9068
format and audienced to the API by an
RFC 8707 resource indicator. The API
works per trust realm, and each realm's administrators have
a token of their own that is confined to their realm.
The OpenAPI 3.1 document is generated from the same table that registers the
routes, so an operation cannot exist without being documented. Read the
document rather than any list on this page: GET /admin-api/openapi.json, or
the explorer on the console at /admin/api-explorer.
The service seeds an application called sts-management-api in every
realm. It is a confidential OAuth client that can only use the
client-credentials grant. It has no redirect URI, it authenticates with
client_secret_basic, and its registered scope is admin:read admin:write.
| Scope | Lets the token |
|---|---|
admin:read |
make any GET request |
admin:write |
make anything else, meaning every POST that changes state |
To get a token, call the token endpoint with the client-credentials grant, the
scopes you need, and resource=<base>/admin-api. The resource value sets
the token's audience, and the API refuses a token audienced to anything else.
On a stack you run, getting in takes two steps. Use sts-management-api
for the first token only
(The first token). With that token,
create an application of your own that holds the admin roles, and use that
application from then on
(An application of your own).
BASE=https://localhost:8081 # the main port is HTTPS by default
TOKEN=$(curl -sk -u "sts-management-api:$ADMIN_API_CLIENT_SECRET" \
--data-urlencode grant_type=client_credentials \
--data-urlencode 'scope=admin:read admin:write' \
--data-urlencode "resource=$BASE/admin-api" \
"$BASE/oauth2/token" | jq -r .access_token)
curl -sk -H "Authorization: Bearer $TOKEN" "$BASE/admin-api/status" | jq
curl -sk -X POST "$BASE/admin-api/config/set" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"key":"groups.claimName","value":"roles"}'(-k is there because development mode signs the listener's certificate with
an authority it generates at each start. See TLS.)
adminApi.clientSecret sets the secret. If it is empty, a new secret is
generated at every start. That secret can only be read through the API it
unlocks, so after a restart nobody can get a token wherever secrets are
checked. Any deployment, and any launcher that starts the service for a test
run, should set adminApi.clientSecret before the service starts, because
the seeded client reads the setting when it is created. After that, the
setting is the only way to change the secret: the default realm's
regenerate-secret action on this client is refused while the secret is
pinned.
You can hand the setting over in two ways:
ADMIN_API_CLIENT_SECRETholds the secret itself, in the environment. This works, but the secret then shows indocker inspectand in the process's environment.ADMIN_API_CLIENT_SECRET_FILEholds the path of a file that contains the secret. The service reads the file once at start, before it loads the protocol stack, and then deletes the file. The value is never put in the process's environment. If the file is empty or cannot be read, the service does not start, because starting without the secret would mint a different one. The same_FILEform works forKRB5_KRBTGT_PASSWORDandKRB5_SERVICE_PASSWORD, and for no other setting.
The compose stack in this repository uses the second form, so the secret is never in the service's environment (see the next section).
In the compose stack (docker compose up), the secret is kept in OpenBao,
at secret/sts-admin in the field adminApiClientSecret. The seeder
generates it on the run that initialises the store. To choose the value
yourself, set ADMIN_API_CLIENT_SECRET in the host's environment before that
first up. Compose passes it to the seeder, which stores it. It does not
pass it to the service.
The service's own OpenBao identity cannot read secret/sts-admin. Instead,
each up gives the service a single-use token that reads the secret once, at
start, and the value reaches the service as a file that it reads and deletes.
To read the secret yourself, use the operator token that the seeder prints
each time it runs. That token can read secret/sts-admin and nothing else,
and it lasts 24 hours (STS_BAO_OPERATOR_TTL). Find it in the seeder's log,
then read the secret with it:
docker compose logs --no-log-prefix openbao-seed \
| grep 'OPERATOR TOKEN' | tail -1 | jq -r .msg
# an OPERATOR TOKEN for the first /admin-api token on this stack — read
# on secret/sts-admin and nothing else, for 24h (docs/management-api.md): <token>
BAO_OPERATOR_TOKEN='<token>' # the value at the end of that line
ADMIN_API_CLIENT_SECRET=$(docker compose exec -T \
-e BAO_TOKEN="$BAO_OPERATOR_TOKEN" openbao \
bao kv get -address=https://127.0.0.1:8200 \
-ca-cert=/openbao/file/tls/server.crt \
-field=adminApiClientSecret secret/sts-admin)Then mint the token as sts-management-api, as the example at the top of
this section does.
- If no operator token is printed,
STS_BAO_PRINT_CREDENTIALS=falseis set. Rundocker compose upagain without that setting, and the seeder prints a new token. - If the operator token has expired, run
docker compose upagain. Every seeder run prints a new one. docker compose restart stsdoes not run the seeder again, so the service starts without its single-use token and without the pinned secret. It then mints a secret of its own that nobody can read. Rundocker compose upto start it with the secret again.- On the run that initialises the store, the seeder also prints OpenBao's
recovery key, once, and nothing keeps a copy of it. The seeder revokes
the root token when it finishes, so nothing on disk holds root. The recovery
key is what
bao operator generate-rootneeds to make a root token again, for example to change a policy. Store it somewhere safe the first time you see it.
Use the first token to set up the credential you will keep using: create an
application, give it a client secret and the ADMIN_READ and ADMIN_WRITE
roles, and get its tokens with the OAuth 2.0 client credentials grant. Each
token it gets carries the admin scopes that its roles allow. Do not keep
using sts-management-api for routine work: its secret is the stack's
bootstrap, and reading it means unlocking the secret store.
The realm you create the application in decides what it may administer:
| Created in | Its token is minted at | It may administer |
|---|---|---|
| the default realm | <base>/oauth2/token, with resource=<base>/admin-api |
every realm: /admin-api and every /realm/<id>/admin-api |
a trust realm <id> |
<base>/realm/<id>/oauth2/token, with resource=<base>/realm/<id>/admin-api |
that realm only, under /realm/<id>/admin-api, and with the same limits as that realm's console administrators (Trust realms and per-realm administrators) |
API. These calls use the first token, in $TOKEN. For an application in
a trust realm, put the realm's prefix on every path
($BASE/realm/<id>/admin-api/…): the roles, the application and the grant
all belong to one realm.
API=$BASE/admin-api # or $BASE/realm/<id>/admin-api
APP=ops-automation # the client_id of your application
api() { # api <path under the API> '<json body>'
curl -sk -X POST "$API/$1" \
-H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d "$2"
}
# 1. A random secret, made by the service. This call stores nothing.
APP_SECRET=$(api applications/generate-secret '{}' | jq -r .clientSecret)
# 2. The application: an OAuth 2.0 client on client_credentials that
# declares the two admin scopes. The secret becomes its credential.
api applications/create '{
"identifier": "'"$APP"'", "name": "'"$APP"'",
"protocols": ["oauth2"],
"fields": {
"oauthClientId": ["'"$APP"'"],
"oauthClientSecret": "'"$APP_SECRET"'",
"oauthGrantType": ["client_credentials"],
"oauthTokenEndpointAuthMethod": "client_secret_basic",
"oauthAllowedScope": ["admin:read", "admin:write"]
}}'
# 3. The two roles.
api roles/add-member '{"role":"ADMIN_READ","kind":"application","member":"'"$APP"'"}'
api roles/add-member '{"role":"ADMIN_WRITE","kind":"application","member":"'"$APP"'"}'From then on, the application gets its own tokens. For an application in the default realm:
TOKEN=$(curl -sk -u "$APP:$APP_SECRET" \
--data-urlencode grant_type=client_credentials \
--data-urlencode 'scope=admin:read admin:write' \
--data-urlencode "resource=$BASE/admin-api" \
"$BASE/oauth2/token" | jq -r .access_token)For an application in realm <id>, call $BASE/realm/<id>/oauth2/token, with
resource=$BASE/realm/<id>/admin-api.
To give an application that already exists a new secret, call
POST …/applications/add-secret with {"application": "<client_id>"}. The
new secret is in clientSecret in the reply, and this reply is the only
place the act hands it out.
Console. On the realm's console (/admin, or /realm/<id>/admin):
- Go to Directory → Applications → New application ›
(
/admin/applications/new). Set Identifier to the client_id, tick OAuth 2.0, and click Generate Secret in The client secret. Copy the value before you create the application. - On the application's page, in Change what it is allowed to do: tick
client_secret_basicunderoauthTokenEndpointAuthMethodand nothing else, addclient_credentialstooauthGrantType, and addadmin:readandadmin:writetooauthAllowedScope. - On
/admin/roles, under Give a person, a group or an application a role, choose the roleADMIN_READ, the kind application and your application's identifier, and click Add. Do the same forADMIN_WRITE.
Why it needs both the scopes and the roles. The token endpoint issues
admin:read and admin:write only to a client whose oauthAllowedScope
declares them, and each one only while the client is a member of the role
that authorizes it: ADMIN_READ for admin:read, ADMIN_WRITE for
admin:write. The API checks both again on every call. So if you take the
application out of a role, or remove a scope from its oauthAllowedScope,
the tokens it already holds stop working at their next call. That is also how
to retire the application.
In development mode, outside RFC 9700 and OAuth 2.1 mode, the token endpoint does not check client credentials. In product mode it does. See what is not checked.
It checks these in order, in both modes, and each failure gets its own refusal:
- Present at all. If there is no token, the answer is 401 with
WWW-Authenticate: Bearer, naming both scopes and theresourceto ask for. - Signed by this service, meaning the default realm's key, or the realm's own key under a realm prefix (see below). Then not expired, and not revoked: a token this service has since revoked is refused, and so is one issued to a person whose account is now disabled.
- An access token. The
typheader must beat+jwt(RFC 9068 section 4), so an ID Token or refresh token signed with the same key is not accepted. - An issuer this service publishes, at the address you are calling. An
issuer is an address, so a token minted under one host name is refused
under another. Mint the token at the address you will call the API at, or
set
global.publicBaseUrl. - The audience. A token for a different audience gets 403. Bearer tokens minted for other resource servers must not be replayable here.
- Its sender constraint, if it has one. A certificate-bound token
(
cnf["x5t#S256"]) must arrive on a connection that presents that certificate. A DPoP-bound token (cnf.jkt) must be sent asAuthorization: DPoP <token>with a valid proof, not as a Bearer token.oauth2.accessTokenRequireDpopandoauth2.accessTokenRequireMtlsapply here as they do at every other resource server in the service (OAuth security). - The scope the method needs. The scopes become the built-in
ADMIN_READandADMIN_WRITEroles, and the XACMLaccess-controlpolicy decides whether that role is enough. A read-only token that tries aPOSTgets a 403 from the policy, and the message names the scopes the token has.
The refusals are in the STS-API-* rows of error codes.
The code is recorded in the audit log and the service log, and is never sent
to the caller.
Every call is written to the audit log, and the row names the subject of the
access token: the person the token was issued for, or the client's id for a
client_credentials token. The rows an operation writes about what it changed
name the same subject. A caller cannot choose the name: an operation's schema
refuses an actor member in a request body, and the name always comes from
the token.
Where no token is required (development mode with adminApi.authRequired
off), a call names nobody.
GET /admin-api/me answers what the API decided about the caller: who the
token names, which realm issued it and whether that makes the caller a service
or a realm administrator, the scopes and roles in effect, whether it may read
and write, and the console pages it may reach. The roles are read on every
call, so a role granted or revoked since the token was minted shows at once.
An access token issued to a person through a sign-in is tied to that sign-on
session. When the session ends, by a sign-out or by running out, the API
refuses the token (401 invalid_token), even though the token itself has not
expired. A person's other sessions, and the tokens issued on them, are not
affected.
A token that no sign-in is behind is not tied to anything: a
client_credentials token works until it expires or is revoked.
Every control added to /admin gets an operation on /admin-api in the
same change. An API that covers eight of nine controls is worse than one
that covers none, because the missing one is found by a caller who has
already written code that assumed it was there. Parity is kept structurally:
- The API decides nothing on its own. Each
POSTcalls the same action function the console's form posts to, with the action taken from the URL (POST /admin-api/tokens/revoke) rather than from a hidden form field. Each console page is DRAWN from itsGET, and each console form is SENT to itsPOST, so the two doors cannot disagree about what is allowed. - A page with no form has only a
GET. The audit log is an example. There is nothing to change, so there is no operation to mirror. - Each operation's description ends by naming the console control it mirrors, so you can look up the page that shows the same thing.
- An unknown action is not a 404. It gets the console's own "Unknown action" refusal, which lists the actions that exist.
- One route per resource, one documented operation per action. Express
registers
/admin-api/tokens/:actiononce; the OpenAPI document lists/admin-api/tokens/revoke,/restore,/revoke-kind,/revoke-subject,/revoke-userand/revoke-allas separate operations, each with its own body schema and example./admin/sts-metadata, which reads the router, shows one row per resource, while the document describes the URLs a caller actually uses.
Nothing in the service can see a form appear on a page, so parity is also
checked from outside, by this repository's tests/vendored/admin_api.js. It
reads the facts from the running service rather than from a list in the test:
the console's page list comes back in GET /admin-api/status, and each action
handler, asked for an action that does not exist, replies with the names of the
ones that do ("Unknown action "x". The four are: add, remove, clear,
replace."). Add an action to the console and that sentence grows, and the test
fails until the API has an operation for it. The same test checks every
property the document describes against a live reply.
The operations are grouped in the OpenAPI document by tag. Broadly, they cover:
- The service itself. Status and totals, metrics, configuration (read,
set,set-many,reset,reset-all), trust realms, token lifetimes, persistence, the database, encryption at rest, secrets, caches, the worker pools, node health, the scheduler, the cluster, the error-code table and the audit log.GET /admin-api/worker-poolsandGET /admin-api/node-healthanswer every cluster node by name, with?node=<name>for one. - Identities. People and groups in the directory, second factors (TOTP,
WebAuthn, recovery codes), password policies, sessions and sign-out. Each
security key on
GET /admin-api/users?user=carriesattestation— what its attestation statement proved (format, type, verified, trusted, the anchor, the model the FIDO metadata names) — andGET /admin-api/webauthnreports the attestation policy in force and the FIDO MDS BLOB (#105). - Applications and what they are issued. The application register,
consent, permissions, roles, delegation and who may delegate (
GET /admin-api/delegation/policy), issued tokens, the claim and SAML attribute sets, and signing keys. - Each protocol family's settings and state. OAuth 2.0 / OIDC and authorization servers, SAML 2.0 and 1.1, WS-Trust, WS-Federation, federation relationships, Kerberos principals, LDAP, SCIM, SPIFFE, Shared Signals (CAEP, RISC), OpenID4VCI, OpenID4VP and credential status, XACML, GNAP, and certificate enrollment (ACME, EST, SCEP).
- The PKI. Certificate authorities, key pairs, certificates and the TLS truststore.
- Administration. The two console roles (
/admin-api/rbac) and the API explorer's own report.
For the full list, with request and response schemas and examples, see
GET /admin-api/openapi.json. The index at GET /admin-api returns the
version and build, whether the API is protected, and a one-line summary of
every operation. Both describe the management operations alone; the
console's own are below.
The admin console is a static application whose one data source is this API,
and some operations exist only to draw it: its frame, its drawings and its
form helpers. Their answers follow the console's pages, which change, so they
are not part of the management API and live apart from it under
/admin-api/console (#454):
| Operation | What it is for |
|---|---|
GET /admin-api/console |
The frame around every page: the sidebar, banners, realm switcher and footer |
GET /admin-api/console/operations |
Every operation of both kinds, for resolving a console form to its operation |
GET /admin-api/console/openapi.json |
The OpenAPI document for these operations |
GET /admin-api/console/dashboard |
The front page: GET /admin-api/status with the base URL, persistence and the visible sections |
GET /admin-api/console/api-explorer |
The explorer page's own facts |
GET /admin-api/console/delegation-settings |
The permissions register as its page lays it out |
GET /admin-api/console/delegation/{map,cluster,allowed,chain}, GET /admin-api/console/federation/map |
The drawings |
GET /admin-api/console/delegation/{user,application} |
One person's or application's delegations, drawn |
GET /admin-api/console/applications/new |
The new application form's layout |
POST /admin-api/console/pki/{apply-profile,generate-keys,generate-alt-keys,use-key} |
Fill the certificate form; write nothing |
POST /admin-api/console/applications/generate-secret |
Mint a secret for the new application form; write nothing |
Every console control is still a management operation. A console
operation is a read or a form helper that writes nothing, and the data behind
a drawing is a management operation too: GET /admin-api/delegation/user,
/delegation/application, /applications/new and /status answer the same
data as their console twins without the drawing or the form's layout, and
refuse format=svg (STS-API-0129). A form helper asked at its old address
is answered 404 (STS-API-0128).
They take the scope admin:console, which is issued only to the console's
client: the ADMIN_CONSOLE role authorizes it, and it is conferred by
sts-admin-console on every person who signs in to the console rather than
held by anybody (roleConferredBy, managed on /admin/roles and with
POST /admin-api/roles/add-conferring-client and
remove-conferring-client). The roles a person holds themselves and the
roles their client confers are mixed on every call. The frame and the
operation list need ADMIN_CONSOLE alone, so a person with no console role is
still told so; every other console operation needs ADMIN_READ too, or
ADMIN_WRITE for a form helper. A token without admin:console, such as
sts-management-api's, is refused 403 (STS-API-0127).
A console client entry persisted before #454 does not declare
admin:console. Add it to that entry's oauthAllowedScope and
oauthGlobalConsent and sign in again.
A POST takes a JSON body. Before the operation runs, the body is checked
against the schema the OpenAPI document publishes for it. An unknown member,
a value of the wrong type, or a value outside the enum a field declares gets
400 { "ok": false, "errors": [...] } (STS-API-0009) and nothing changes.
Every POST replies with the console action's own result: ok is always
present, and the other members depend on the action.
A field that takes only certain values declares them as an enum in the
OpenAPI document, and that one list is enforced everywhere the value can be
typed: a request body, a query parameter (STS-API-0124), and the console form
that the operation mirrors (STS-ADMIN-0820). Each refusal names the field,
the value, and every value it accepts:
"deliver" is "fax", which is not one of the 2 values it accepts: "show", "mail".
Case must match exactly. An empty string counts as "not given". A setting
written through /config/set-many is held to its own list: an enum
setting's enumValues, and for a comma-separated setting, every entry against
its csvValues. GET /admin-api/config publishes both.
/admin/api-explorer (Server configuration → API explorer) is a console page,
behind the console's session and roles. It shows the management API's document
— the console's own operations are not in it — a form for each operation, the
response, and
the equivalent curl command. Its Try it button calls /admin-api with a
token the page mints for you. That token carries only the scopes your console
roles grant (admin:read for Admin Read, admin:write for Admin Write). The
API still checks it on every call, so a reader with only Admin Read who tries
a POST gets the same 403 as from a terminal.
The explorer mints a default-realm token, which is a service credential, so a
realm's own administrators cannot see it. It also stops working while
oauth2.accessTokenRequireDpop is on, because its script sends a plain Bearer
header and has no DPoP key to prove. With that setting on, use curl and a
proof.
The explorer is the one console page with a script. The console is
script-src 'none' like the rest of the service, which is what makes the whole
family of reflected-content problems moot rather than merely unlikely. The
explorer relaxes that on its own two routes and in exactly two clauses:
script-src 'self', and connect-src 'self' so the page can call the API it
documents. Everything else, default-src 'none' included, stays as it is, and
the console next door is still script-src 'none' — which the tests assert.
The script is a separate file because 'self' is enough for a file where an
inline block would need 'unsafe-inline', the clause that would make the
relaxation matter. It is served verbatim from disk, is not a node module, and
builds every element with createElement rather than assigning innerHTML,
because it renders response bodies that are not always this service's own.
The API is scoped to a realm by the same path prefix as every other endpoint.
/admin-api/config is the default realm's configuration and
/realm/acme/admin-api/config is acme's. A set posted to the second
changes acme alone. Every operation works per realm.
The five operations under /admin-api/realms manage the realm registry,
and there is one registry per process. GET /admin-api/realms returns the
same list under any prefix, with current naming the realm the call arrived
in. remove refuses to remove that realm, because the caller would be left
calling a prefix that no longer exists.
Two kinds of token are accepted:
| Token | Minted at | Accepted at | May do |
|---|---|---|---|
| The service token | the default realm's /oauth2/token, with resource=<base>/admin-api |
/admin-api and every /realm/<id>/admin-api |
everything its scopes allow |
| A realm's own token | /realm/<id>/oauth2/token as that realm's sts-management-api, with resource=<base>/realm/<id>/admin-api |
/realm/<id>/admin-api only |
that realm's operations only |
Either token must have been issued to a client that declares the scope it
uses (#110). admin:read and admin:write are issued only to a
client whose oauthAllowedScope lists them, in both modes — any other client is
refused invalid_scope at the token endpoint — and the API asks again on every
call, in the realm that issued the token: a token whose client no longer
declares the scope an operation needs is refused 403 (STS-API-0123), so
withdrawing the declaration cuts off tokens already issued. The seeded
sts-management-api and sts-admin-console declare both in every realm; an
administrator may declare them on another client with
POST /admin-api/applications/add. A dynamic registration may not.
The subject of a token must also hold the role the scope goes with
(#302, #303). ADMIN_READ and ADMIN_WRITE are roles on
/admin/roles that authorize admin:read and
admin:write. They are seeded in every realm, and sts-management-api is a
member of both.
- A person holds them through the console's Admin Read and Admin Write
groups. A person signing in through a client that declares the scopes (by the
authorization code flow, a refresh, a token exchange or another grant) gets
only the scopes their console roles cover. While the console is still open to
everybody (in development, before the bootstrap administrator's first
sign-in), everybody gets both. The bootstrap administrator gets neither until
that first sign-in to
/adminwith its password. - An application on
client_credentialsgets a scope only while it is a member of the role. To let another client use the API, declare the scope on it and add it to the role:POST /admin-api/roles/add-memberwith{"role": "ADMIN_READ", "kind": "application", "member": "<client>"}.
A scope the subject's roles do not cover is left off the token, and the token
response's scope says what was issued. If nothing else was requested, the
request is refused with invalid_scope. The API checks again on every call: a
token whose subject has since lost the role is refused with 403
(STS-API-0125), even before it expires.
A realm's token is tried only under that realm's prefix. It must carry the
realm's issuer and audience. The token is then
refused whatever the console refuses the realm's administrators
(STS-API-0112), and the same rule decides both doors:
- service pages: persistence, database, encryption, secrets, the debugger, TLS and its truststore, the LDAP service page, and the API explorer;
- creating or removing a realm, or naming another realm;
build-rootor a*scope on the PKI, and exporting thetls-serverkey;- any setting that belongs to the process: every
admin.*,adminApi.*,realms.*,workers.*,persistence.*,debugger.*,tls.*andkeys.*row,global.mode,global.publicBaseUrland the listener settings, among others.
A realm's client secret is generated at each start and is not pinned by
adminApi.clientSecret, which applies only to the default realm's client. A
realm administrator reads or regenerates it on their realm's console at
/realm/<id>/admin/applications?application=sts-management-api.
Trust realms covers realm administrators on the console side.
With adminApi.authRequired on (the default), both modes require the token
and check it the same way. Only development mode can turn it off. What
global.mode changes around the API:
| Development | Product | |
|---|---|---|
The token, when adminApi.authRequired is on |
required and verified | required and verified |
The client secret at /oauth2/token |
not checked, outside RFC 9700 / OAuth 2.1 mode | checked (product mode implies RFC 9700 mode) |
adminApi.authRequired off |
the API is open to anybody who can reach the port | ignored: the token is still required. Writing false is refused (STS-CORE-0103), and a false already stored is read as true and logged once (STS-CORE-0106) |
The off switch exists because it is the recovery path in development. If
nobody can mint a token, it restores the open API so that
POST /admin-api/rbac/grant can give somebody a console role again. That also
means anybody who can reach the port can grant themselves both roles,
which is as dangerous as it sounds.
Product mode has no off switch (since 2026-10-05). This API's gate is
becoming the admin console's only gate, so a setting that opened the API
would open the console too. Until then product fell back to the console's own
session and roles when the switch was off; a console session is no longer a
credential here in either mode. The recovery path in product is the seeded
sts-management-api client: set adminApi.clientSecret before the service
starts, so a token can always be minted. In the compose stack that secret is
in OpenBao, and the seeder's operator token reads it
(The first token). An application of
your own that holds both roles is a second way in, and it does not depend on
the secret store.
Whatever the state, whoever can call this API can revoke every token this service has issued and change what the next one contains. Do not expose a development-mode instance on a public address.
The policy layer runs wherever the token is required. In development with the switch off there is no credential and so no subject, and a policy that refused an unauthenticated subject would close the recovery path, so it is not asked there.
adminApi.* and admin.* apply to the whole process: a trust realm cannot
override them, because they decide who administers the service.
| Setting | Environment variable | Default | Runtime? | What it does |
|---|---|---|---|---|
adminApi.authRequired |
ADMIN_API_AUTH_REQUIRED |
true |
yes | Require an access token on every /admin-api call; off restores the open API. Off is honoured in development mode only: product refuses it and ignores it where it is stored. |
adminApi.clientSecret |
ADMIN_API_CLIENT_SECRET |
empty (generated per start) | no | The client_secret of the default realm's seeded sts-management-api client; set it so the secret survives a restart. Secret. ADMIN_API_CLIENT_SECRET_FILE names a file to read it from instead; the file is deleted once read. |
adminApi.audience |
ADMIN_API_AUDIENCE |
derived: global.publicBaseUrl + /admin-api, or this process's scheme, host and port + /admin-api |
yes | The aud a token must carry. At its default, /admin-api under the host the request arrived on is accepted as well; any other value pins that one value. |
admin.readGroup |
ADMIN_READ_GROUP |
admin-read |
yes | The directory group whose members hold Admin Read, which lets them read the console and, with the token gate off in product mode, GET the API. |
admin.writeGroup |
ADMIN_WRITE_GROUP |
admin-write |
yes | The directory group whose members hold Admin Write; write implies read. |
admin.openWhenEmpty |
ADMIN_OPEN_WHEN_EMPTY |
true |
yes | Keep the console open to every signed-in person until the bootstrap administrator first signs in. Development mode only: product never opens it. |
admin.bootstrapUsername |
STS_ADMIN_BOOTSTRAP_USERNAME |
admin |
no | The administrator seeded at startup in every realm, a member of both roles, with a forced password change. |
admin.bootstrapPassword |
STS_ADMIN_BOOTSTRAP_PASSWORD |
empty | no | In product mode, the bootstrap administrator's password instead of a generated one that is logged once. Secret; held to the password policy. |
xacml.enforceAccess |
STS_XACML_ENFORCE_ACCESS |
true |
yes | Ask the XACML policy before letting a caller through; on the token path this is what checks the scope. |
oauth2.accessTokenRequireDpop |
STS_OAUTH2_ACCESS_TOKEN_REQUIRE_DPOP |
false |
yes | Refuse any access token that is not DPoP-bound and proved, here and at every other resource server; the explorer stops working. |
oauth2.accessTokenRequireMtls |
STS_OAUTH2_ACCESS_TOKEN_REQUIRE_MTLS |
false |
yes | Refuse any access token that is not bound to the certificate the connection presents (RFC 8705). |
global.publicBaseUrl |
STS_PUBLIC_BASE_URL |
empty (read from each request) | yes | The base of every issuer and address the service builds, including this API's default audience. |
global.mode |
STS_MODE |
development |
yes | development or product; decides whether adminApi.authRequired=false is honoured. Per trust realm. |
The adminApi.* and admin.* groups are edited on /admin/rbac. See
Configuration for how values resolve, and for changing
them on the console or with POST /admin-api/config/set.
- An API, because a form is the right shape for a person and the wrong one
for anything else. Every console page answered
?format=jsonfrom the start, so reading was never the problem; changing something was. Since the console became a static application over this API (2026-10-05), the API is not a second door beside the console but the only one: the console is its client. Without the API a script that wanted to revoke a token, or a CI job narrowing the issuer's claim set before running a wallet against it, would have to parse a 303 redirect for its message or know which hidden input a form carried — driving a browser without one. - The API mirrors the console and decides nothing. Both doors call one
action function and one read view, so they cannot differ on what is
allowed. The console page is drawn from the same model the API returns.
The way to see it is not to read the code: revoke a token through the API
and RFC 7662 introspection calls it inactive, because there is one set of
revoked jtis in the service and
/oauth2/revokewrites to the same one. - The OpenAPI document is generated, not written. It is built from the route table, so it cannot describe an operation that does not exist or omit one that does. The explorer's copy is built by the same function.
- A token, not a console session.
/admin-apiis a machine surface with no browser and no sign-in screen, so it is reached the way a machine reaches any resource server. A console session is not an API credential, and a token is not a console session. - One gate on the base path. It covers all of the several hundred operations. A check in each handler would give the next operation a chance to be added without one.
- Scopes become roles, and the policy decides.
admin:readandadmin:writebecome the built-inADMIN_READandADMIN_WRITEroles, and the XACMLaccess-controldocument asks for the role each method needs. The requirement is stated where every other access decision in the service is stated, and a policy can name the roles without knowing about OAuth. Withxacml.enforceAccessoff, that question is not asked, so any valid token audienced to the API gets through whatever its scope. - The audience is checked, and so is the token type. A Bearer token for
another resource server must not be replayable here. Because every token
the service signs uses the same key, without the
at+jwtcheck an ID Token whose audience happened to name the API would be an administrative credential. - The service token is verified with the default realm's key, wherever it is presented. If a token minted in a realm counted as the service credential, anybody who could create a realm could mint a token for everything. A realm's own token is therefore believed only in that realm.
admin:*is tied to the client, in both realms and both modes (#110). Otherwise any client that could useclient_credentialscould mint Admin Write. The token endpoint issues them only to a client whoseoauthAllowedScopedeclares them, a registration cannot declare them, and the gate asks the same question of every token (STS-API-0123).- The secret is a configuration setting, not an action. A secret
generated per start is readable only through the API it unlocks. That is a
bootstrap hole, so
adminApi.clientSecretpins it, and regenerating a pinned secret is refused rather than letting the entry and the setting disagree. - An off switch that restores the open API. It is the way back in when nobody can mint a token and nobody holds a console role. It is a separate setting from anything on the console, so turning the API's gate off never opens the console.
- The policy layer cannot remove the recovery path. With the token gate off, the policy is asked only in product mode, where a session supplies the subject.
- Where an API write records who acted, it records
via: apiand no person. The API authenticates a client, not a person, and has no session to name one. POST /admin-api/logout/endwith an empty selection is refused. Global logout is its own named operation,global, because an empty list arriving atendusually means a caller built a list and got nothing.- The explorer is the service's own, not Swagger UI. It is about 450
lines with no dependency, where
swagger-ui-distwould add about 11.7 MB and an install-time telemetry package, in an image built where there may be no network beyond the registry. It does what was needed — read the document, fill a form, show the response — plus the equivalentcurlline beside each operation, which is what an operator actually copies. It is one of the few pages allowed a script, and it loads the script as a file (script-src 'self'), never inline. - The document declares the credential. It carries
security, twosecuritySchemes, and the scope on every operation.security: []would be OpenAPI for no credential is needed, and a client generated from it would send none and be refused by every operation.tests/admin_api_document_security.jsandtests/vendored/sts_admin_api_auth.jshold the document to the gate from both ends. - A test still needs no secret of its own. The test launchers mint one token before any job runs and hand it to every job.
| Where | What |
|---|---|
GET /admin-api |
The index: name, version and build, whether the API is protected, where the document and the explorer are, and a summary of every operation |
GET /admin-api/openapi.json |
The OpenAPI 3.1 document, with servers[0].url set to the address the request reached. Behind the gate like everything else |
GET /admin-api/status |
The cheapest call, and the one to poll: the issuer, the start time and the running totals |
GET /admin-api/me |
What the API decided about the caller: who, which authority, the roles in effect and the pages they may reach |
/admin/api-explorer |
The explorer, on the console; GET /admin-api/api-explorer reports where the document is, how many operations it describes and what your roles would grant |
/admin/rbac |
The two console roles, and the adminApi.* and admin.* settings; GET /admin-api/rbac and POST /admin-api/rbac/{grant,revoke} |
/admin/applications?application=sts-management-api |
The seeded client in this realm, including its secret |
/admin/xacml |
The access-control policy the scope check runs through |
/admin/error-codes |
The STS-API-* codes behind each refusal, also at GET /admin-api/error-codes |
/admin/audit |
The audit log, also at GET /admin-api/audit |
At startup the service logs how many operations the API has and whether a token is currently required.
- Admin console: the pages every operation mirrors
- Applications: the registry, including the seeded
sts-management-apiclient - Configuration: how settings resolve, and the runtime
configoperations - Trust realms: realm prefixes and per-realm administrators
- Accepted tokens: what each resource server here accepts
- What is not checked: the gated surfaces, by mode
- OAuth 2.0 & OpenID Connect: the token endpoint and the client-credentials grant
- OAuth security: DPoP, mutual TLS, RFC 9700 mode
- XACML: the access-control policy
- Error codes
- Endpoints