Enrollment
How a device proves it's allowed to join the fleet and how OpenMDM validates it. Covers both the legacy HMAC path and the preferred device-pinned-key path.
Enrollment
Enrollment is the one moment in a device's life where it has no server identity yet. Everything that makes the post-enrollment world secure — device-scoped tokens, heartbeats, command authorization — is bootstrapped from this single HTTP request.
OpenMDM supports two enrollment paths and runs them side-by-side during any rollout:
- Device-pinned-key (Phase 2b, preferred) — the device generates an ECDSA P-256 keypair in its own Android Keystore, registers the public key with the server on first enroll, and all future enrollments for that device must present a signature produced by the same private key. No shared secret exists on the wire. Compatible with any Android device including non-GMS vendor boards (ZK-R32D, kiosk hardware, etc.) — nothing about this path depends on Google's hardware attestation infrastructure.
- HMAC (Phase 2a, legacy) — the agent signs the enrollment request with a shared
DEVICE_SECRETbaked into the APK at build time. Still supported. Anyone who extracts the APK can forge enrollments against this path, so treat it as a transitional state rather than the target.
The server picks the path per request based on whether the request carries a publicKey. Fleets can migrate gradually: ship a new agent build that uses pinned keys, let it propagate, and when every device has re-enrolled on the new path you can flip enrollment.pinnedKey.required = true to lock out the legacy HMAC path.
The shape of an enrollment request
interface EnrollmentRequest {
// Device identifiers (at least one required)
macAddress?: string;
serialNumber?: string;
imei?: string;
androidId?: string;
// Device info
model: string;
manufacturer: string;
osVersion: string;
sdkVersion?: number;
// Enrollment metadata
method: 'qr' | 'nfc' | 'zero-touch' | 'knox' | 'manual' | 'app-only' | 'adb';
timestamp: string; // ISO 8601
signature: string; // HMAC hex (Phase 2a) OR DER ECDSA base64 (Phase 2b)
// Phase 2b fields (omit for the legacy HMAC path)
publicKey?: string; // base64-encoded SPKI EC P-256 public key
attestationChallenge?: string; // nonce from GET /agent/enroll/challenge
// Optional pre-assignment
policyId?: string;
groupId?: string;
}If publicKey is present, the server runs the device-pinned-key path. Otherwise it runs the HMAC path (requires enrollment.deviceSecret in createMDM).
Path A: device-pinned-key (preferred)
The full round trip
┌──────┐ ┌──────────┐
│Device│ │ Server │
└──┬───┘ └────┬─────┘
│ │
│ 1. GET /agent/enroll/challenge │
├────────────────────────────────────────────────►│
│ │ createEnrollmentChallenge(
│ │ challenge=random(32 bytes),
│ │ expiresAt=now+5m)
│ │
│ { challenge, expiresAt, ttlSeconds } │
│◄────────────────────────────────────────────────┤
│ │
│ 2. generate EC P-256 keypair in Keystore │
│ (hardware-backed if available) │
│ │
│ 3. sign canonical message with private key │
│ signature = ECDSA(privateKey, message) │
│ where message = publicKey | model | ... │
│ ... | challenge │
│ │
│ 4. POST /agent/enroll │
│ { ...deviceInfo, method, timestamp, │
│ publicKey, attestationChallenge, │
│ signature } │
├────────────────────────────────────────────────►│
│ │
│ importPublicKeyFromSpki(...) │
│ consumeEnrollmentChallenge(...) │
│ (atomic, single-use) │
│ verifyEcdsaSignature(...) │
│ if device exists and has a │
│ pinned key, submitted key │
│ MUST match the pinned one → │
│ PublicKeyMismatchError │
│ pin publicKey on first enroll │
│ enrollment_method = 'pinned-key'│
│ │
│ 5. 200/201 { deviceId, token, serverUrl, ... } │
│◄────────────────────────────────────────────────┤The canonical signed message
publicKey | model | manufacturer | osVersion | serialNumber | imei | macAddress | androidId | method | timestamp | challengeEleven fields joined with |. Empty optional identifiers are rendered as empty strings, not omitted. The signature is DER-encoded ECDSA-SHA256 produced by the device's Keystore private key, base64-encoded on the wire.
The canonical form is load-bearing — any drift between @openmdm/core's canonicalEnrollmentMessage and the agent's local implementation silently breaks enrollment for every new device. The contract test at packages/core/tests/device-identity.test.ts pins the current shape.
The continuity property
The core security property of the pinned-key path: a device's identity cannot be hijacked by an attacker who extracts the enrollment secret. On every re-enrollment for an already-pinned device, the server verifies the submitted signature against the pinned public key, not against whatever public key the request is currently offering. An attacker can forge anything, including the public key field, but cannot produce an ECDSA signature that verifies against a key whose private half lives in a device's hardware-backed Keystore on the other side of the world.
If a device legitimately needs to rebind (full wipe, new hardware, key rotation), an admin unpins the device manually — there is no automatic rebind on mismatch. The error surface is explicit:
import { PublicKeyMismatchError } from '@openmdm/core';
try {
await mdm.enroll(request);
} catch (err) {
if (err instanceof PublicKeyMismatchError) {
// Device-side identity does not match what we pinned.
// Admin intervention required.
}
}Server configuration
export const mdm = createMDM({
database: drizzleAdapter(db, { tables: mdmSchema }),
// ...
enrollment: {
deviceSecret: process.env.DEVICE_SECRET!, // still needed for the legacy path
autoEnroll: true,
pinnedKey: {
// Accept both paths during rollout. Flip to true after every
// device has re-enrolled on the pinned-key path.
required: false,
// Challenge TTL in seconds. 5 minutes by default.
challengeTtlSeconds: 300,
},
},
});Enabling the pinned-key path requires a database adapter that implements the enrollment-challenge surface. The Drizzle adapter does this on Postgres — pass pluginStorage: mdmSchema.mdmPluginStorage and enrollmentChallenges: mdmSchema.mdmEnrollmentChallenges through DrizzleAdapterOptions.tables. Adapters that do not implement challenge storage cause the challenge endpoint to return 503, which is the correct failure mode: the server will not hand out challenges it cannot later verify.
Path B: HMAC (legacy)
The HMAC path is unchanged from prior releases. Full details in PR #9 / enrollment signature. Quick reference:
Canonical form — nine fields joined with |:
model | manufacturer | osVersion | serialNumber | imei | macAddress | androidId | method | timestampSignature: HMAC_SHA256(deviceSecret, message).hex()
The HMAC path requires enrollment.deviceSecret to be configured. If you configure enrollment.pinnedKey.required = true, the HMAC path is disabled and requests without publicKey are rejected with a clear error.
Reusing the same identity outside OpenMDM
The device-pinned-key path is designed to be reused. Consumers of @openmdm/core can import two primitives and verify requests against the same pinned public key without needing to understand ECDSA or SPKI themselves:
import {
verifyDeviceRequest,
canonicalDeviceRequestMessage,
} from '@openmdm/core';
// In a middleware somewhere else (different service, different framework):
const canonical = canonicalDeviceRequestMessage({
deviceId: req.header('x-device-id')!,
timestamp: req.header('x-device-timestamp')!,
body: await req.text(),
});
const result = await verifyDeviceRequest({
mdm,
deviceId: req.header('x-device-id')!,
canonicalMessage: canonical,
signatureBase64: req.header('x-device-signature')!,
});
if (!result.ok) {
return res.status(401).json({ error: result.reason });
}
// result.device is the authenticated Device row.verifyDeviceRequest returns a tagged union — callers pattern-match on result.reason to decide between not-found (unknown device id, return 401), no-pinned-key (device is still on the HMAC path, fall back to your legacy verifier), and signature-invalid (return 401 and do NOT re-pin).
This is how a fleet built on OpenMDM can have one device identity verified consistently by multiple services — the agent talks to OpenMDM's /agent/* routes, the device's player/renderer/etc. talks to its own backend, and both verify the same ECDSA signature against the same pinned key. No shared HMAC secret, no per-service key rotation, no inconsistent trust boundaries.
The two secrets (still)
deviceSecret— HMAC secret for the legacy enrollment path. Lives in the APK and the server. Phase 2b does not use it.deviceTokenSecret— JWT signing secret for device bearer tokens issued after enrollment. Same as before. Phase 2b does not change this.
Once a device has enrolled via the pinned-key path, the server still issues a bearer token for its subsequent /agent/* requests. The token is still HMAC-signed JWT. A future Phase 2c will replace bearer tokens with ECDSA-signed request envelopes using the same pinned key, closing the post-enrollment authentication gap. Until then, bearer tokens remain the post-enrollment credential.
What the server does on a valid enrollment
- Parse and validate the request body (
EnrollmentRequest). - Determine the path:
publicKeypresent → pinned-key path; absent → HMAC path (or reject ifpinnedKey.required). - Pinned-key path:
- Import the submitted SPKI public key (reject if not valid EC P-256).
- Atomically consume the
attestationChallenge(reject if missing, expired, or already used). - Verify the ECDSA signature over
canonicalEnrollmentMessage(...). - If the device already exists and has a pinned key, enforce
submittedKey === pinnedKey(PublicKeyMismatchErroron mismatch).
- HMAC path: verify
HMAC_SHA256(deviceSecret, canonicalForm). - Run any
enrollment.validate(request)custom validator. - Upsert the device row by
enrollmentId, pinning the public key on first pinned-key enrollment. - Assign default policy, fire
device.enrolledevent, run plugin hooks. - Issue a device JWT and return the enrollment response.
What can go wrong
Pinned-key path
ChallengeInvalidError. Challenge was never issued, has expired pastchallengeTtlSeconds, or was already consumed. Agent should fetch a fresh challenge and retry.PublicKeyMismatchError. The device tried to re-enroll with a different public key than the one originally pinned. This is either a full-wipe recovery (admin must unpin manually) or an attack. Surface a loud operational event.InvalidPublicKeyError. The submitted SPKI is malformed, not EC, or not P-256. Agent bug on the device side.- Signature does not verify. Canonical forms drifted, or the agent generated the signature with the wrong private key. Check the contract test results.
HMAC path
All pre-Phase-2b failure modes still apply: secret mismatch between APK and server, nine-field canonical drift, missing identifier.
The dev harness
See Quick Start §4 for a small script that signs an EnrollmentRequest with your local DEVICE_SECRET and calls mdm.enroll() directly, bypassing the HTTP layer. It uses the HMAC path because it doesn't have a real Keystore handy; that's fine for development. For local pinned-key testing you can generate a keypair via Node's built-in crypto.generateKeyPairSync('ec', { namedCurve: 'P-256' }) and use canonicalEnrollmentMessage + sign('sha256', ..., privateKey) to produce a valid request.
Where to go next
- Agent Wire Protocol — how v1/v2 clients handle envelope failures.
- Architecture — the full data flow including the heartbeat loop that follows enrollment.
- Phase 2b rollout proposal — the Android-side work required to adopt device-pinned-key enrollment, including the gaps in the upstream openmdm-android agent and the TLS pinning that must land alongside.