Kiosk Recipe
Lock devices to a single app, handle exit attempts, and unlock stuck devices.
Kiosk Recipe
Kiosk mode is the most common reason teams adopt MDM. You have a fleet of devices running exactly one app — a POS terminal, a check-in kiosk, a digital sign — and you need the device to boot into that app, stay in it, and refuse to do anything else.
This recipe covers the full lifecycle: enabling kiosk via policy, handling exit attempts, and unlocking a stuck device when a lockout triggers.
Enable the plugin
Kiosk is shipped as an optional plugin. Add it to your createMDM config and enable pluginStorage so lockout state survives restarts and scales horizontally:
import { createMDM } from '@openmdm/core';
import { kioskPlugin } from '@openmdm/plugin-kiosk';
export const mdm = createMDM({
database: drizzleAdapter(db, { tables: mdmSchema }),
push: fcmPushAdapter({ ... }),
enrollment: { deviceSecret: process.env.DEVICE_SECRET! },
auth: { deviceTokenSecret: process.env.JWT_SECRET! },
// REQUIRED for kiosk in production. The kiosk plugin persists
// lockout counters, the active kiosk app, and exit-attempt history
// through this store. If it's omitted, state lives only in this
// process's memory and is lost on restart or across replicas.
pluginStorage: { adapter: 'database' },
plugins: [
kioskPlugin({
// Fallback exit password if the policy doesn't set one.
defaultExitPassword: process.env.KIOSK_EXIT_PASSWORD!,
// Let an admin exit kiosk mode via a command from the server,
// not just via the on-device password.
allowRemoteExit: true,
// Restart the kiosk app automatically if it crashes.
lockOnCrash: false,
autoRestart: true,
autoRestartDelay: 2000,
// After 5 wrong exit passwords, lock the device for 15 minutes.
// This is the UX that prevents someone from brute-forcing at the
// kiosk screen while you're not looking.
maxExitAttempts: 5,
lockoutDuration: 15,
}),
],
});Plugins register routes, hooks, and state tracking at instance creation. Once the plugin is loaded, it wires itself into the policy application flow automatically — you don't need to import anything else to use it.
Why pluginStorage is required in production. Without it, the kiosk plugin falls back to an in-memory
Mapand prints a startup warning. That fallback is fine for local development, but it silently breaks two things you care about: (1) process restarts lose the lockout timer, so a malicious user who hit the max exit attempts gets a fresh budget every time your server redeploys, and (2) running a second replica behind a load balancer means each replica has its own view of the counters, so exit attempts aren't consistently counted. Both are real bugs that have bitten production fleets. EnablingpluginStorage: { adapter: 'database' }persists state through the sameDatabaseAdapteryou already configured.
Create a kiosk policy
Kiosk settings live on the policy, not on the plugin options. The plugin is the engine; the policy is the configuration:
const kioskPolicy = await mdm.policies.create({
name: 'Retail POS Kiosk',
description: 'Locks devices to the POS app with status bar hidden',
isDefault: true,
settings: {
kioskMode: true,
mainApp: 'com.example.pos',
lockStatusBar: true,
lockNavigationBar: true,
disableHomeButton: true,
disableRecentApps: true,
},
});Fields available in PolicySettings for kiosk behavior:
| Field | Type | Effect |
|---|---|---|
kioskMode | boolean | Master switch. Plugin is a no-op unless this is true. |
mainApp | string | Package name of the app the device is locked to. Required when kioskMode: true. |
allowedApps | string[] | For multi-app kiosk scenarios. Users can only launch apps in this list. |
lockStatusBar | boolean | Hide the status bar entirely. |
lockNavigationBar | boolean | Hide the nav bar (back/home/recents). |
disableHomeButton | boolean | Intercept the home button. |
disableRecentApps | boolean | Intercept the recent-apps button. |
disablePowerButton | boolean | Soft-disable the power button (the hardware button still works, but short-presses are eaten). |
disableVolumeButtons | boolean | Silence the volume rockers. |
kioskExitPassword | string | Per-policy override of the plugin's defaultExitPassword. |
All of these are honored by the Android agent through the Device Policy Manager API. The server-side SDK just ensures the settings are delivered correctly — the enforcement happens on the device.
Assign the policy
// A specific device
await mdm.devices.assignPolicy(deviceId, kioskPolicy.id);
// Or as the default — every new device that enrolls without an
// explicit assignment inherits this policy
await mdm.policies.setDefault(kioskPolicy.id);Devices pick up the new policy on their next heartbeat. Expect a 0–60s delay depending on your heartbeat interval.
Handle exit attempts
When a user tries to exit kiosk mode (by entering a password or triggering a gesture), the device calls one of the plugin's routes. The plugin emits events you can subscribe to:
mdm.on('security.tamper', async (event) => {
if (event.payload.type === 'kiosk.exit-attempt') {
const { deviceId, attempts, lockedOut } = event.payload;
console.warn(
`[kiosk] exit attempt ${attempts} on ${deviceId}${lockedOut ? ' (LOCKED OUT)' : ''}`,
);
if (lockedOut) {
await alertOps({
channel: 'security',
text: `Device ${deviceId} is locked out for 15 minutes after ${attempts} failed kiosk exit attempts`,
});
}
}
});Exit-attempt state is tracked per-device by the plugin. The counter resets on a successful exit (correct password) or when the lockout timer expires.
Unlock a stuck device
Two failure modes you need to be able to recover from:
Failure mode 1: lockout timer still running
When a device is locked out, it will refuse further exit attempts until the timer expires. If you need to unlock it earlier (e.g. an admin is on site and needs access), send a remote exit command:
await mdm.devices.sendCommand(deviceId, {
type: 'exitKiosk',
payload: {
// The server-side override password you set via defaultExitPassword
// or kioskExitPassword on the policy.
password: process.env.KIOSK_EXIT_PASSWORD!,
},
});The device executes the command on its next heartbeat (or sooner if the push wakes it up). The command also clears the lockout state and resets the exit-attempt counter.
allowRemoteExit: truemust be set on the plugin for this to work. It's a deliberate footgun — if you don't trust your own admin-side auth, you don't want to expose a remote exit path.
Failure mode 2: wrong mainApp package
If you ship a policy with a typo in mainApp, the device will lock itself to an app that doesn't exist and become unrecoverable from the UI. To fix this remotely, push an updated policy that points to a valid app, then trigger an immediate sync:
await mdm.policies.update(policy.id, {
settings: { ...currentSettings, mainApp: 'com.example.correct' },
});
await mdm.devices.sync(deviceId); // force a heartbeat nowIf the device has completely lost connectivity and can't heartbeat, there is no remote recovery and you need physical access. The safest prevention is to always test new kiosk policies on one device before promoting them to the fleet default. The CLI makes this easy:
npx openmdm policy create --file test-kiosk.json
npx openmdm policy apply <policyId> <one-test-device-id>
# verify manually, then
npx openmdm policy apply <policyId> <every-other-device>Testing your kiosk policy
Because kiosk mistakes can brick real hardware, you want a testing loop tight enough to iterate in minutes:
- Run the dev enrollment harness (Quick Start §4) against a test device or emulator.
- Assign the kiosk policy to just that device.
- Observe in the
device.heartbeatevent handler that the device reports the policy as applied. - Try to exit — first with the wrong password, then the right one. Watch for the
security.tamperevent. - Hit the lockout limit to verify the counter and timer work.
- Use the CLI (
openmdm device show <id>) to confirm policy state from outside your app.
Once you're confident, promote the policy to default or assign it to a group.
Where to go next
- Commands vs Policies — why kiosk config lives on the policy, not the plugin options.
- Webhooks Recipe — wire the
security.tamperevents to Slack or PagerDuty for live fleet alerts.