Enrollment Keys
Enrollment keys are short-lived, usage-limited tokens that authorize new agents to register with the Breeze API. Each key is scoped to an organization and optionally pinned to a specific site, ensuring that agents land in the correct place in the multi-tenant hierarchy without exposing long-lived credentials.
Key Concepts
Section titled “Key Concepts”| Concept | Description |
|---|---|
| Enrollment key | A 64-character hex string (32 random bytes) presented by the agent during the POST /api/v1/agents/enroll call. |
| SHA-256 + pepper | The raw key is never stored. It is hashed with a server-side pepper before being written to the database. |
TTL (expiresAt) |
Time-to-live. After this timestamp the key is rejected. Default: 60 minutes from creation. |
Max usage (maxUsage) |
Maximum number of successful enrollments allowed. Default: 1. Range: 1 – 100,000. |
Usage count (usageCount) |
Incremented atomically on each successful enrollment. Once it reaches maxUsage, the key is exhausted. |
Site pinning (siteId) |
When set, every agent that enrolls with this key is placed into the specified site. The enrollment endpoint requires a siteId to be present on the key. |
| Enrollment secret | A separate, static secret (AGENT_ENROLLMENT_SECRET environment variable) that gates the enrollment endpoint in production. This is independent of the enrollment key itself. |
Per-Key Enrollment Secrets
Section titled “Per-Key Enrollment Secrets”Each enrollment key can optionally have its own enrollment secret, separate from the global AGENT_ENROLLMENT_SECRET environment variable. When a per-key secret is set, it takes priority over the global secret during enrollment.
This is useful when you want to restrict enrollment to specific keys with unique credentials – for example, giving each customer site its own secret so that a leaked key from one site cannot be used with another site’s installer.
Per-key secrets are set when creating or rotating a key. If no per-key secret is set, the global AGENT_ENROLLMENT_SECRET is used as usual.
Installer Downloads
Section titled “Installer Downloads”Each enrollment key can generate pre-configured Windows MSI and macOS PKG installers. These installers are pre-loaded with the enrollment key and site assignment, so agents enroll automatically when the installer runs.
When MSI signing is configured on the server, Windows installer downloads are automatically code-signed before delivery — Windows SmartScreen and antivirus tools will recognize them as trusted. See Code Signing for setup details.
Downloading from the Dashboard
Section titled “Downloading from the Dashboard”From the enrollment key list or detail view, click Download Installer and choose the platform (Windows or macOS). The installer is generated on the fly and downloaded to your browser.
Downloading via API
Section titled “Downloading via API”# Download a pre-configured installer for a specific keycurl -OJ https://breeze.yourdomain.com/api/v1/enrollment-keys/KEY_UUID/installer/windows \ -H "Authorization: Bearer $TOKEN"
curl -OJ https://breeze.yourdomain.com/api/v1/enrollment-keys/KEY_UUID/installer/macos \ -H "Authorization: Bearer $TOKEN"Public Download Links
Section titled “Public Download Links”You can generate a shareable, token-authenticated URL so that site contacts can download the installer without needing dashboard access.
# Generate a public download linkcurl -X POST https://breeze.yourdomain.com/api/v1/enrollment-keys/KEY_UUID/installer-link \ -H "Authorization: Bearer $TOKEN"
# The response includes a URL like:# /api/v1/enrollment-keys/public-download/windows?token=...Share the resulting URL with on-site contacts. Public download links are rate-limited to prevent abuse.
Short Codes (/s/<code>)
Section titled “Short Codes (/s/<code>)”Public installer links are also issued with a short, human-friendly URL of the form https://breeze.yourdomain.com/s/<code>. The short URL automatically detects the requester’s platform and serves the correct installer (Windows MSI or macOS PKG), and increments the enrollment key’s usageCount atomically — so the same maxUsage limit applies whether the installer is downloaded via the long-form link or the short link.
Short codes are convenient for sharing in email, chat, or printed runbooks. The platform is selected per-request from the User-Agent and can be overridden with a ?platform=windows or ?platform=macos query parameter.
In the dashboard, the enrollment key list shows each key’s short code in a Short code column with a copy-to-clipboard button — this replaces the previous always-masked KEY column, which never revealed anything useful. A Hide expired toggle above the list filters out keys that have already expired.
Installer Bootstrap Tokens
Section titled “Installer Bootstrap Tokens”Every installer download, installer-link generation, or short-code redemption mints an installer_bootstrap_tokens row: a single-use-by-default token embedded in the installer that authenticates its first-run /bootstrap call, independent of the interactive enrollment key that produced it.
The token’s TTL defaults to INSTALLER_BOOTSTRAP_TOKEN_TTL_MINUTES (24 hours) or the admin’s chosen link expiry, clamped to the partner’s maxEnrollmentLinkTtlMinutes cap if one is set — see Partner & Organization Enrollment Defaults. The daily purge sweep also will not delete a parent enrollment key while it still has a live, unexhausted bootstrap token outstanding, even past the key’s own grace period — see Automatic Purge of Expired Keys.
Enrollment Key Lifecycle
Section titled “Enrollment Key Lifecycle”Creating a Key
Section titled “Creating a Key”- Authenticate with a user session that has the
organizations:writepermission and has completed MFA. - Send a
POSTrequest to/api/v1/enrollment-keyswith the desired scope. - Copy the
keyfield from the 201 response. This is the only time the raw key is returned. - Embed the key in your agent installer, deployment script, or MDM payload.
curl -X POST https://breeze.yourdomain.com/api/v1/enrollment-keys \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "orgId": "ORG_UUID", "siteId": "SITE_UUID", "name": "Chicago Office Q1 Deploy", "maxUsage": 50, "expiresAt": "2026-04-01T00:00:00Z" }'Request body:
| Field | Type | Required | Default | Description |
|---|---|---|---|---|
orgId |
uuid |
Depends | Inferred for org-scoped users | Target organization. Required for partner and system scopes. |
siteId |
uuid |
No | null |
Pin enrolled agents to a specific site. The enrollment endpoint will reject keys without a siteId. |
name |
string |
Yes | – | Human-readable label (1–255 characters). |
maxUsage |
integer |
No | 1 |
Maximum enrollments allowed (1–100,000). |
expiresAt |
datetime |
No | Now + TTL | ISO 8601 absolute expiration. Mutually exclusive with ttlMinutes. |
ttlMinutes |
integer |
No | Now + TTL | Lifetime in minutes from creation (1 to 525600, i.e. up to 365 days). Mutually exclusive with expiresAt. |
When neither expiresAt nor ttlMinutes is supplied, the key expires after ENROLLMENT_KEY_DEFAULT_TTL_MINUTES (default 60). Supplying both is rejected so the resolved expiry is unambiguous.
POST and PATCH on /enrollment-keys reject any field that isn’t in the table above. A typo like maxUses (instead of maxUsage) now returns 400 Bad Request with a precise field-name error rather than silently dropping the value — useful when scripting key creation against earlier API examples that used different field names.
Response (201):
{ "id": "a1b2c3d4-...", "orgId": "ORG_UUID", "siteId": "SITE_UUID", "name": "Chicago Office Q1 Deploy", "usageCount": 0, "maxUsage": 50, "expiresAt": "2026-04-01T00:00:00.000Z", "createdBy": "USER_UUID", "createdAt": "2026-03-02T12:00:00.000Z", "key": "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1b2"}Listing Keys
Section titled “Listing Keys”# All non-expired keys for the authenticated orgcurl https://breeze.yourdomain.com/api/v1/enrollment-keys?expired=false \ -H "Authorization: Bearer $TOKEN"
# Filter by org (partner/system scope)curl "https://breeze.yourdomain.com/api/v1/enrollment-keys?orgId=ORG_UUID&page=1&limit=25" \ -H "Authorization: Bearer $TOKEN"Query parameters:
| Parameter | Type | Description |
|---|---|---|
page |
string |
Page number (default 1). |
limit |
string |
Results per page (default 50, max 100). |
orgId |
uuid |
Filter by organization. |
expired |
true or false |
Filter by expiration status. |
The response includes a pagination object with page, limit, and total fields.
Rotating a Key
Section titled “Rotating a Key”Rotation generates new key material for an existing record while resetting usageCount to zero. Use it to extend a deployment window or reissue a compromised key without changing the key ID.
curl -X POST https://breeze.yourdomain.com/api/v1/enrollment-keys/KEY_UUID/rotate \ -H "Authorization: Bearer $TOKEN" \ -H "Content-Type: application/json" \ -d '{ "maxUsage": 100, "expiresAt": "2026-06-01T00:00:00Z" }'| Field | Type | Required | Description |
|---|---|---|---|
maxUsage |
integer or null |
No | New limit. Pass null for unlimited. Omit to keep the current value. |
expiresAt |
datetime |
No | New expiration. Omit to keep the current value. |
The old key value is immediately invalidated. The response includes the new key field.
Deleting a Key
Section titled “Deleting a Key”Enrollment keys are hard deleted from the database. This is irreversible.
curl -X DELETE https://breeze.yourdomain.com/api/v1/enrollment-keys/KEY_UUID \ -H "Authorization: Bearer $TOKEN"Automatic Purge of Expired Keys
Section titled “Automatic Purge of Expired Keys”Expired enrollment keys are hard-deleted automatically by a daily sweep that runs at 04:00 UTC. A key is only purged once it has been expired for a grace period of ENROLLMENT_KEY_PURGE_AFTER_DAYS days (default 7), so a recently expired key remains visible long enough to audit or rotate. Keys with no expiration date are never auto-purged. Set ENROLLMENT_KEY_CLEANUP_ENABLED=false to disable the sweep entirely.
A key is also skipped by the sweep — even past its grace period — while it still has a live, unexhausted installer bootstrap token outstanding, so a long-lived installer link (30 days, 1 year) cannot be purged out from under itself before its own token expires or is fully consumed.
Installer bootstrap tokens and deployment invites that reference a purged key are cascade-deleted along with it.
Bulk-Deleting Expired Keys
Section titled “Bulk-Deleting Expired Keys”You can also delete all expired keys on demand, without waiting for the grace period:
curl -X POST https://breeze.yourdomain.com/api/v1/enrollment-keys/purge-expired \ -H "Authorization: Bearer $TOKEN"The endpoint requires the same organizations:write permission + MFA as single-key deletion, deletes every expired key in the caller’s scope (the caller’s org, all accessible orgs for partner scope, or all orgs for system scope), and returns { "success": true, "deletedCount": N }. Keys without an expiration are never matched.
In the dashboard, this is surfaced as a Delete expired button above the key list, with a confirmation dialog before anything is removed.
How Agent Enrollment Uses the Key
Section titled “How Agent Enrollment Uses the Key”When an agent starts for the first time, it presents the enrollment key to the API. The full flow is:
- The agent sends
POST /api/v1/agents/enrollwith the raw enrollment key, hostname, OS type, architecture, and agent version. - In production, the API first validates the static enrollment secret (from the
AGENT_ENROLLMENT_SECRETenv var orx-agent-enrollment-secretheader). - The API hashes the enrollment key with SHA-256 + pepper and looks up the matching record.
- The API checks that the key has not expired (
expiresAt > NOW()) and has remaining usage (usageCount < maxUsage). usageCountis atomically incremented.- The API verifies the key has a
siteId. If not, the enrollment is rejected and the usage increment is rolled back. - A device record is created (or updated if the hostname already exists in the same org + site) with a new
agentIdandagentTokenHash. - Hardware and network inventory from the enrollment payload is stored.
- An mTLS certificate is issued if Cloudflare mTLS is configured for the organization.
- The API returns the
agentId,deviceId,authToken(abrz_-prefixed bearer token),orgId,siteId, and heartbeat configuration.
// Successful enrollment response (201){ "agentId": "hex-agent-id", "deviceId": "uuid", "authToken": "brz_a1b2c3d4...", "orgId": "uuid", "siteId": "uuid", "config": { "heartbeatIntervalSeconds": 60, "metricsCollectionIntervalSeconds": 30 }, "mtls": null}Security Model
Section titled “Security Model”Peppered Hashing
Section titled “Peppered Hashing”Enrollment keys are hashed using SHA-256 with a server-side pepper before storage. The pepper is read from environment variables in this priority order:
ENROLLMENT_KEY_PEPPERAPP_ENCRYPTION_KEYSECRET_ENCRYPTION_KEYJWT_SECRET
In production, at least one of these must be set or the server will refuse to start.
The hash is computed as:
SHA-256( pepper + ":" + rawKey )This means that even if the database is compromised, the raw enrollment keys cannot be recovered without knowledge of the pepper.
Dual Gate: Secret + Key
Section titled “Dual Gate: Secret + Key”The enrollment endpoint uses two layers of protection:
| Layer | Purpose | Scope |
|---|---|---|
Enrollment secret (AGENT_ENROLLMENT_SECRET) |
Static gating token validated at the start of the request. Required in production if set. | Global – same for all enrollments. |
| Enrollment key | Per-deployment token that determines org, site, and usage limits. | Per-key – each deployment batch gets its own key. |
The enrollment secret can be passed as the enrollmentSecret field in the request body or via the x-agent-enrollment-secret header.
Re-enrollment
Section titled “Re-enrollment”If an agent with the same hostname already exists in the same org and site, the enrollment endpoint updates the existing device record rather than creating a duplicate. This supports scenarios like OS reinstalls or agent upgrades. However, if the existing device has been decommissioned, re-enrollment is blocked with a 403 error.
Audit Trail
Section titled “Audit Trail”Every enrollment key operation is recorded in the audit log:
| Action | Trigger |
|---|---|
enrollment_key.create |
New key created. |
enrollment_key.rotate |
Key material regenerated. Includes previous and new maxUsage, expiresAt, and usageCount. |
enrollment_key.delete |
Key permanently deleted. |
enrollment_key.purge_expired |
Bulk deletion of expired keys. Includes the deletedCount. |
agent.enroll |
Agent successfully enrolls using a key. Logged with actorType: agent. |
Multi-Tenant Access Control
Section titled “Multi-Tenant Access Control”Enrollment key management respects the Breeze multi-tenant hierarchy:
| User Scope | Behavior |
|---|---|
| Organization | Can only manage keys for their own organization. orgId is inferred automatically. |
| Partner | Can manage keys for any organization they have access to. Must provide orgId when managing multiple orgs. If the partner has exactly one org, orgId is inferred. |
| System | Can manage keys for any organization. Must provide orgId. |
All management endpoints require organizations:read for listing/viewing and organizations:write + MFA for creating, rotating, and deleting.
Partner & Organization Enrollment Defaults
Section titled “Partner & Organization Enrollment Defaults”Partners and organizations can configure three settings that shape enrollment link and installer TTLs without touching environment variables per-deployment. All three live under settings.defaults on the partner or organization record.
| Setting | Scope | Behavior |
|---|---|---|
defaultEnrollmentTtlMinutes |
Partner default, inherit-with-override by org | Pre-selects the link/installer expiry in the Add Device dialog. A partner sets a house default; an individual organization may override it with its own default. |
defaultEnrollmentDeviceCount |
Partner default, inherit-with-override by org | Same inherit-with-override pattern, for the default device count on a new link. |
maxEnrollmentLinkTtlMinutes |
Partner only | A hard ceiling on enrollment key/installer lifetime for every organization under that partner. An organization cannot raise it — there is no org-level equivalent field. |
Partners configure all three from their partner settings page (PATCH /partners/me); organizations override the first two from their own org settings page (PATCH /orgs/organizations/:id). See Partner Management for the general settings-PATCH mechanics.
Rejected vs. clamped
Section titled “Rejected vs. clamped”The cap is enforced two different ways, depending on whether there’s an interactive caller present to show an error to:
-
An explicit request above the cap is REJECTED with 400, not silently reduced.
POST /enrollment-keys,POST /enrollment-keys/:id/rotate, the installer / installer-link download routes, and the device onboarding-token route all validate an explicitttlMinutes(orexpiresAt) against the partner’s cap before doing anything else. A caller asking for more than the cap allows gets400with{ "error": "ttlMinutes exceeds the partner maximum of <N> minutes" }— the key is never created with a shorter TTL as a fallback. -
A system-picked TTL is CLAMPED to the cap instead of rejected, because there is no request to reject and no user to show an error to. This applies to:
- the fresh child enrollment key minted when an installer is redeemed (
/s/:code,/bootstrap), - the child enrollment key embedded in an installer download or installer link when the admin didn’t pass an explicit
ttlMinutes(GET /enrollment-keys/:id/installer/:platform,POST /enrollment-keys/:id/installer-link) — it falls back to the deployment default, which is then clamped, - the base TTL of a freshly-issued installer bootstrap token when the admin didn’t choose an explicit expiry, and
- deployment invites generated by the AI/MCP
send_deployment_invitestool.
In the dashboard, the Link expires in picker in the Add Device modal also hides any preset longer than the effective cap (for example, a 24-hour cap removes the 7-day/30-day/90-day/1-year options), so most admins never hit the 400 path at all — it exists for direct API/script callers and forged requests.
- the fresh child enrollment key minted when an installer is redeemed (
The practical consequence: this also shortens the completion window, not just the download window
Section titled “The practical consequence: this also shortens the completion window, not just the download window”A short cap doesn’t just limit how long an installer link is downloadable. After an installer is run, it redeems its embedded bootstrap token and is issued a brand-new, single-use child enrollment key — and that child key’s TTL is also clamped to the same partner cap. So a one-hour cap gives a machine one hour from redemption to complete the actual POST /api/v1/agents/enroll call, on top of however long the bootstrap token itself was valid for download.
In normal operation that gap is seconds. It stops being seconds — and starts producing “invalid or expired enrollment key” support tickets — whenever something delays the agent’s first enrollment call after the installer runs: disk-imaging pipelines, a reboot baked into the install sequence, MDM-staged rollouts, or a machine that sits offline for a while right after setup. If enrollment fails on a machine that “was definitely installed within the download window,” check whether a partner cap is also clamping the post-redemption completion window.
“Never expires” is not offered
Section titled ““Never expires” is not offered”There is no option to mint a key or installer link that never expires, by design. installer_bootstrap_tokens.expires_at is NOT NULL with a CHECK (expires_at > created_at) constraint at the database level — supporting “never” would need a migration to make the column nullable, plus null-handling added through issuance, the /bootstrap consume path, the daily purge sweep, and the expiry countdown in the dashboard UI. That is tracked as a follow-up, not implemented here.
Environment Variables
Section titled “Environment Variables”| Variable | Default | Description |
|---|---|---|
ENROLLMENT_KEY_DEFAULT_TTL_MINUTES |
60 |
Default time-to-live for new enrollment keys when expiresAt is not specified. |
CHILD_ENROLLMENT_KEY_TTL_MINUTES |
1440 |
Fresh TTL (minutes) given to a “child” enrollment key minted at installer-download, installer-link, or short-code redemption time, measured from mint time rather than the parent key’s remaining lifetime. Clamped to the partner’s maxEnrollmentLinkTtlMinutes cap. |
INSTALLER_BOOTSTRAP_TOKEN_TTL_MINUTES |
1440 |
Base TTL (minutes) for a freshly-issued installer bootstrap token when no explicit ttlMinutes is supplied. The token’s own expiry, not the parent enrollment key’s, governs how long an installer has to complete enrollment. |
INSTALLER_PARENT_MIN_REMAINING_SECONDS |
60 |
Minimum remaining lifetime (seconds) a parent enrollment key must have to be used as an installer source. Guards against building an installer from a parent that expires before the download reaches the target machine. |
ENROLLMENT_KEY_PEPPER |
– | Pepper used for SHA-256 hashing of enrollment keys. Falls back to APP_ENCRYPTION_KEY, SECRET_ENCRYPTION_KEY, or JWT_SECRET. |
AGENT_ENROLLMENT_SECRET |
– | Static secret that gates the enrollment endpoint in production. If empty or unset, the gate is skipped in non-production environments. |
ENROLLMENT_KEY_CLEANUP_ENABLED |
true |
Set to false to disable the daily sweep that purges expired keys. |
ENROLLMENT_KEY_PURGE_AFTER_DAYS |
7 |
Grace period (in days past expiry) before the daily sweep hard-deletes an expired key. |
API Reference
Section titled “API Reference”All routes are prefixed with /api/v1/enrollment-keys and require JWT authentication.
| Method | Path | Permission | Description |
|---|---|---|---|
GET |
/enrollment-keys |
organizations:read |
List enrollment keys with pagination and filters. |
POST |
/enrollment-keys |
organizations:write + MFA |
Create a new enrollment key. Returns the raw key once. |
GET |
/enrollment-keys/:id |
organizations:read |
Get metadata for a single enrollment key (raw key not included). |
POST |
/enrollment-keys/:id/rotate |
organizations:write + MFA |
Regenerate key material, reset usage count. Returns the new raw key. |
DELETE |
/enrollment-keys/:id |
organizations:write + MFA |
Permanently delete an enrollment key. |
POST |
/enrollment-keys/purge-expired |
organizations:write + MFA |
Delete all expired enrollment keys in the caller’s scope. Returns the number of keys deleted. |
GET |
/enrollment-keys/:id/installer/:platform |
organizations:read |
Download a pre-configured MSI or PKG installer. |
POST |
/enrollment-keys/:id/installer-link |
organizations:write |
Generate a public download link for an installer. |
GET |
/enrollment-keys/public-download/:platform |
Token (public) | Download an installer via a public link token. |
GET |
/s/:code |
Public (short code) | Auto-detects platform and redirects to the matching installer download. Increments enrollment-key usage atomically. |
The agent enrollment endpoint is separate:
| Method | Path | Auth | Description |
|---|---|---|---|
POST |
/api/v1/agents/enroll |
Enrollment secret + enrollment key | Register a new agent. No JWT required. |
Troubleshooting
Section titled “Troubleshooting”“Invalid or expired enrollment key” (401)
Section titled ““Invalid or expired enrollment key” (401)”The SHA-256 hash of the provided key did not match any active record, or the key has expired or reached its usage limit. Possible causes:
- The key was rotated and the old value is being used in the installer.
- The
expiresAttimestamp has passed. The default TTL is 60 minutes. usageCounthas reachedmaxUsage. Check the key details viaGET /enrollment-keys/:id.- The server pepper changed since the key was created. Rotate or recreate the key.
“Enrollment secret required” (403)
Section titled ““Enrollment secret required” (403)”The AGENT_ENROLLMENT_SECRET environment variable is set in production but the agent did not provide it. Pass the secret via the enrollmentSecret field in the JSON body or the x-agent-enrollment-secret HTTP header.
“Invalid enrollment secret” (403)
Section titled ““Invalid enrollment secret” (403)”The provided enrollment secret does not match the configured AGENT_ENROLLMENT_SECRET. The comparison uses timing-safe equality to prevent timing attacks. Verify the secret value in your deployment configuration.
“Enrollment key must be associated with a site” (400)
Section titled ““Enrollment key must be associated with a site” (400)”The enrollment key was created without a siteId. The enrollment endpoint requires every key to specify which site agents should be placed into. Delete this key and create a new one with a siteId.
“Device has been decommissioned” (403)
Section titled ““Device has been decommissioned” (403)”An agent with the same hostname exists in the same org and site, but its status is decommissioned. Contact an administrator to either remove the decommissioned record or assign the agent to a different site.
“Organization context required” (403)
Section titled ““Organization context required” (403)”An organization-scoped user attempted a key management operation without a valid orgId in their session. This usually indicates a misconfigured user account.
“No enrollment key pepper configured” (server startup)
Section titled ““No enrollment key pepper configured” (server startup)”In production, at least one of ENROLLMENT_KEY_PEPPER, APP_ENCRYPTION_KEY, SECRET_ENCRYPTION_KEY, or JWT_SECRET must be set. Without a pepper, enrollment key hashes are vulnerable to rainbow table attacks.
Key appears valid but agent fails to connect after enrollment
Section titled “Key appears valid but agent fails to connect after enrollment”- Verify the
authTokenreturned by enrollment is stored correctly in the agent config file. - Check that
secrets.yamlpermissions are0600(owner read/write only) andagent.yamlis0640. - Ensure the agent is connecting to the correct WebSocket URL (
/api/v1/agents/:id/ws) with theAuthorization: Bearer brz_...header.