env-master
Documentation sections

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:

ParameterDefaultWhat it means
mode—universal or enrollment — see below
accessTokenTtlSeconds900access token TTL in seconds, from 60 to 86400
allowedKeysall keysexact names or prefixes with an asterisk
allowedCidrsany addressCIDR 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

CodeWhenWhat it means
401 UNAUTHENTICATEDtoken unknowna typo, or a token not from this system
403 ENROLLMENT_EXPIRED10 minutes passed, token unusedroutine: issue a new identity — no alert here
403 ENROLLMENT_USEDtoken already usedalert, 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:

  1. 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.
  2. 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 a 409 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 a newIp flag 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.