Services and identities
How what you deploy gets access to secrets: the service, the identity, and the two issuance modes — a permanent client secret and a one-time enrollment token.
Service
A service is what you deploy: a worker, an API, a cron job. The container the credentials are bound to. A service by itself reads nothing — access comes from an identity.
You can create a service in the dashboard (the project's "Services" section) or with a request:
curl -s $BASE/projects/$PROJECT/services \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{"name":"Billing worker","slug":"billing-worker"}'
The slug is lowercase letters, digits, and hyphens (billing-worker),
unique within the project; a duplicate is rejected with 409 CONFLICT.
The required permission is the admin role in the project. Deleting a
service (DELETE /services/:id) cascades and deletes all its identities.
To rename a service — PATCH /services/:id with a single name field (in
the dashboard, "rename" on the service card). The permission is the same as
for everything else about services: admin in the project or admin in the
organization.
The slug does not change. It is the address the API and the CLI use to
call the service, and it is unique within the project: changing it would
break everything that refers to it. A slug sent in PATCH is not silently
ignored but rejected with VALIDATION — a silently ignored field looks like
a successful edit, after which people look for the service at a new address
and do not find it. If you need a different slug, create a different service.
Identity
A machine identity (just "identity" from here on) is the credentials of one
service in one environment. If a service runs in both staging and
production, it has two identities. An identity's scope is project ×
environment × an optional set of keys: it cannot read another environment
or someone else's project, no matter where the request comes from.
Creation (in the dashboard — from the service card):
curl -s $BASE/services/$SERVICE/identities \
-H "authorization: Bearer $TOKEN" -H 'content-type: application/json' \
-d '{
"name": "prod worker",
"environmentId": "'$ENV'",
"mode": "universal",
"allowedKeys": ["DB_*", "REDIS_URL"],
"allowedCidrs": ["203.0.113.0/24"],
"accessTokenTtlSeconds": 900
}'
The required permission is specifically the project-level admin role: the
organization owner or admin role is not enough here. This is deliberate:
an identity reads values, including restricted, which is always closed
to humans — so it is issued by someone who has access to the project's
data, not just its settings. For the organization owner this is not a
block, just an extra step: they grant themselves a project role with one
command, and that step stays in the log.
Parameters:
| Parameter | Default | What it means |
|---|---|---|
mode | — | universal or enrollment — see below |
accessTokenTtlSeconds | 900 | access token TTL in seconds, from 60 to 86400 |
allowedKeys | all keys | exact names or prefixes with an asterisk |
allowedCidrs | any address | CIDR list the access is allowed from |
allowedKeys: an empty list means "everything", not "nothing"
An element is either an exact key name (DB_PASSWORD) or a prefix with an
asterisk at the end (DB_*). There are no other masks: a pattern is either
a valid key name or a valid prefix plus *. A typo is rejected at identity
creation with a VALIDATION error instead of silently locking the service
out on its first request.
The main trap is the empty-list semantics: if allowedKeys is not set or
is empty, the identity reads all keys in the environment. Restriction
starts with the first element of the list. There is no way to express
"this service needs nothing" this way — just don't create an identity
until you need one.
The filter is applied after shared secrets references are resolved: a key that arrived via a reference from a shared project is checked by name the same way as a local one. The restriction cannot be bypassed with a reference.
allowedCidrs
A CIDR list; empty means any address. Checked when a token is issued
(login and enrollment token exchange) and on every entry read. If the
client address cannot be determined and a list is set, access is denied:
an unknown IP is not considered trusted. A login denial is recorded in the
log as identity.login-denied; an exchange denial as enrollment.denied.
Universal mode: a permanent client secret
The creation response contains clientSecret:
{
"identity": { "id": "...", "clientId": "mi_..." },
"clientSecret": "..."
}
The client secret is shown exactly once; only its SHA-256 remains in the database. A lost secret is not recovered — a new identity is issued.
The service then logs in with this pair:
curl -s $BASE/machine/login -H 'content-type: application/json' \
-d '{"clientId":"mi_...","clientSecret":"..."}'
In response — an access token with the TTL from the identity's
accessTokenTtlSeconds and the scope "project × environment". In practice
the SDK makes this request for you: it also passes a
publicKey — an ephemeral x25519 pair generated per process — and the
server updates the stored key on every login.
This mode answers the question "how do I log in when the client secret is already on the machine". But first it has to get there — and wherever it has been along the way (an image, a repository, CI logs), it stays there forever. For the case where there is no secure delivery channel, there is the second mode.
Enrollment mode: a one-time enrollment token
With mode: "enrollment" the creation response is different:
{
"identity": { "id": "...", "clientId": "mi_..." },
"enrollmentToken": "...",
"enrollmentExpiresAt": "2026-08-28T12:10:00.000Z"
}
An enrollment token is a one-time bootstrap token: the service exchanges
it for permanent client ID and client secret on its first start. The token
lives for 10 minutes (the exact deadline is in enrollmentExpiresAt),
is shown once, and only its hash is stored in the database.
The two modes have different leak costs. A permanent client secret that lands in a CI log works until revoked — and you may never learn that someone is using it. An enrollment token is useless after the exchange, so its leak after the fact costs nothing. But if the token is intercepted before the exchange and used first — your exchange attempt becomes the second one, and that is no longer just a refusal but an alert (see below).
Exchange
POST /machine/enroll
{ "enrollmentToken": "...", "publicKey": "..." }
publicKey is required — a fresh x25519 key in base64; the server will
wrap the data key under it for the first read. The response returns both
the permanent credentials and the first token at once:
{
"clientId": "mi_...",
"clientSecret": "...",
"accessToken": "...",
"expiresAt": "2026-08-28T12:15:00.000Z"
}
After the exchange the identity switches to universal mode: from then on
it logs in with the regular client ID + client secret pair via
/machine/login, and the SDK will replace the public key with its own on
the first login.
The SDK does the exchange. The usual path is EnvMasterClient.connect():
it finds the credentials in the file, and if they are not there yet, exchanges
the token and stores the result.
const client = await EnvMasterClient.connect({
baseUrl: process.env.ENVMASTER_URL!,
project: "billing",
environment: "prod",
// The path is always explicit, never defaulted: a machine's secret belongs
// where you put it.
credentialsFile: "/var/lib/myapp/envmaster.json",
enrollmentToken: process.env.ENVMASTER_ENROLLMENT_TOKEN,
});
The order inside is: given credentials → file → exchange. The exchange comes last because it is irreversible. Before it the SDK verifies that the file can actually be written — otherwise the token would be spent with nowhere to put the answer, and the service would be locked out for good.
For the same reason an exchange without credentialsFile is refused and
needs allowEnrollWithoutFile: true: credentials stored nowhere disappear with
the process. The separate steps are exported as enroll(),
readCredentialsFile() and writeCredentialsFile() if you would rather store
them yourself.
The CLI still has no exchange. Below is the direct API request, for doing the exchange outside the application (the key is needed once, for the exchange itself, so openssl generates it):
PUB=$(openssl genpkey -algorithm X25519 | \
openssl pkey -pubout -outform DER 2>/dev/null | tail -c 32 | base64)
curl -s $BASE/machine/enroll -H 'content-type: application/json' \
-d "{\"enrollmentToken\":\"$ENROLLMENT_TOKEN\",\"publicKey\":\"$PUB\"}"
The process MUST store the returned clientId and clientSecret: the token is
one-time, and without storing them the next start is locked out. After that the
identity works like a regular universal. The exchange is rate-limited: 5 attempts per 15 minutes — a
script retrying on a timer will hit 429 RATE_LIMITED.
Exchange errors
| Code | When | What it means |
|---|---|---|
401 UNAUTHENTICATED | token unknown | a typo, or a token not from this system |
403 ENROLLMENT_EXPIRED | 10 minutes passed, token unused | routine: issue a new identity — no alert here |
403 ENROLLMENT_USED | token already used | alert, see below |
An expired but unused token gets its own error precisely so a stale deploy is not confused with an interception.
Reuse is an alert
Token capture is atomic (compare-and-set on usedAt), so two competing
attempts cannot both win: exactly one gets the permanent credentials. The
loser gets 403 ENROLLMENT_USED, an enrollment.reuse-detected event is
written to the log, and a webhook is sent — one of
the two events you should wire up in any case.
A second exchange attempt means someone else has seen the token: either the successful exchange was not yours, or the token leaked from a log and was used. In both cases the permanent credentials issued at the first exchange must be treated as compromised:
- Revoke the identity —
DELETE /services/:id/identities/:identityId(in the dashboard — the "Revoke" button). Revocation is instant: already-issued access tokens stop being accepted from the next request. - Issue a new identity and repeat the issuance.
The server does not revoke the identity itself: automatic revocation on a second presentation would turn any enrollment token leak into a ready-made way to kill a running service — presenting the intercepted token one more time would be enough. The decision stays with the human who sees the alert.
Renaming an identity
PATCH /services/:id/identities/:identityId with a single name field (in
the dashboard, "rename" on the identity row). The client_id does not change:
the identity authenticates with it, and changing it would break a running
service.
The permission is the admin role in the project specifically — the same
as for issuing. An organization role by itself is not enough: the identity's
name signs its events in the audit log and in webhooks, and whoever can rename
someone else's machine can pass it off as another one during an incident
review.
A rename is written to the log as an identity.renamed event together with
the previous name — without it, reviewing a year-old event would lie, calling
the identity by a name it did not have back then. A revoked identity can be
renamed too: giving "deploy 12345" a human name is exactly what you want when
reviewing after the fact.
Revocation and lifetimes
- Revocation (
revokedAt) takes effect instantly and is irreversible; a repeated revocation is a409 CONFLICT. The check happens on every request, so the access token TTL is not a vulnerability window. - Every identity login is written to the log (
identity.login, with anewIpflag if the address is new), every read as well (entries.fetch, with the actual set of keys served). - An identity with no clients does not die on its own: if a service is decommissioned, revoke it — inactivity does not close access.