End-to-end security architecture for the OpenClaw single-VPS deployment. Covers network perimeter, authentication layers, device pairing protocol, and dashboard access control.
Three defense layers protect the system. Each layer must pass before the next is evaluated:
Internet
|
v
βββββββββββββββββββββββββββββββββββββββββββ
β Layer 1: Cloudflare Edge β
β - Cloudflare Tunnel (outbound-only) β
β - Cloudflare Access (identity + JWT) β
β - No inbound ports exposed β
βββββββββββββββ¬ββββββββββββββββββββββββββββ
|
v
βββββββββββββββββββββββββββββββββββββββββββ
β Layer 2: Gateway Authentication β
β - Shared secret (GATEWAY_TOKEN) β
β - Device identity (Ed25519 keypair) β
β - Device pairing (admin approval) β
βββββββββββββββ¬ββββββββββββββββββββββββββββ
|
v
βββββββββββββββββββββββββββββββββββββββββββ
β Layer 3: OS & Container Isolation β
β - Two-user model (adminclaw/openclaw) β
β - Sysbox runtime (rootless containers) β
β - Read-only sandbox filesystems β
β - Network isolation (no outbound) β
β - Capability drop (ALL) β
βββββββββββββββββββββββββββββββββββββββββββ
The VPS has zero exposed ports beyond SSH. All HTTP/WebSocket traffic enters through a Cloudflare Tunnel, which makes outbound-only connections from the VPS to Cloudflare's edge.
User Browser Cloudflare Edge VPS
| | |
|ββββ HTTPS request βββββββββββ>| |
| | |
| | cloudflared (outbound conn) |
| |<ββββββββββββββββββββββββββββββββββ|
| | |
| |ββββ Forward via tunnel ββββββββ> |
| | (Docker bridge β container) |
| | |
|<ββββ HTTPS response ββββββββββ|<ββββββββββββββββββββββββββββββββ |
- Port 443: Not open. Cloudflare terminates TLS at the edge;
cloudflaredconnects outbound. - Port 222: SSH only (key-based,
adminclawuser, fail2ban protected). - Docker port binding: All containers bind to
127.0.0.1only viadaemon.json. - Gateway
--bind lan: Required because cloudflared connects via Docker bridge network (10.200.0.100), not loopback. Tunnel routes use Docker DNS container names.
Every request to the gateway or dashboard domain passes through Cloudflare Access, which enforces identity verification before the request reaches the VPS.
User Cloudflare Access VPS
| | |
|ββ GET /dashboard/ ββββββββ>| |
| | |
| Not authenticated? | |
|<ββ Redirect to IdP ββββββββ| |
| | |
|ββ IdP login ββββββββββββββ>| |
| | |
| Authenticated: | |
| Set CF_Authorization | |
| cookie + inject | |
| Cf-Access-Jwt-Assertion | |
| header | |
| |ββ Forward with JWT header βββ>|
| | |
|<βββ Response ββββββββββββββ|<βββββββββββββββββββββββββββββ |
JWT claims verified by the dashboard:
expβ Token not expiredissβ Issuer contains.cloudflareaccess.comaudβ MatchesCF_ACCESS_AUD(if configured)- Signature β RSA-SHA256 verified against Cloudflare's published public keys (fetched from
{issuer}/cdn-cgi/access/certs, cached 1 hour)
The gateway uses a cryptographic device identity system to authenticate clients (Control UI, CLI). Every client generates a long-lived Ed25519 keypair and must be explicitly paired by an administrator before it can interact with the gateway.
ββ Browser (first visit) ββββββββββββββββββββββββββββββββββββββ
β β
β 1. Generate Ed25519 keypair β
β privateKey = randomSecretKey() (32 bytes) β
β publicKey = derivePublic(privateKey) (32 bytes) β
β β
β 2. Derive deviceId β
β deviceId = SHA-256(publicKey) β hex (64 chars) β
β β
β 3. Store in localStorage β
β key: "openclaw-device-identity-v1" β
β val: { version, deviceId, publicKey, privateKey, β
β createdAtMs } β
β β
β Identity persists across sessions until localStorage β
β is cleared. Same keypair = same deviceId. β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Server-side (CLI, gateway self-identity): Same derivation using Node.js crypto.generateKeyPairSync("ed25519"). Stored at ~/.openclaw/identity/device.json with mode 0600.
Every Control UI or CLI connection to the gateway follows this challenge-response handshake:
Browser Gateway
| |
|ββββ WebSocket connect ββββββββββββββββββββββββ>|
| |
| βββββββββββββββββββββββββββββββββββ |
|<ββββββββββ event: "connect.challenge" ββββ|
| β payload: { β |
| β nonce: "<uuid-v4>", β |
| β ts: 1707123456789 β |
| β } β |
| βββββββββββββββββββββββββββββββββββ |
| |
| Build auth payload (pipe-delimited): |
| ββββββββββββββββββββββββββββββββββββββββ |
| β v2 (version) β |
| β |<deviceId> (sha256 hex) β |
| β |control-ui (clientId) β |
| β |webchat (clientMode) β |
| β |operator (role) β |
| β |operator.admin,... (scopes csv) β |
| β |1707123456789 (signedAtMs) β |
| β |<device-token> (if paired) β |
| β |<nonce-uuid> (from above) β |
| ββββββββββββββββββββββββββββββββββββββββ |
| |
| Sign payload with Ed25519 private key |
| signature = Ed25519.sign(payload, privateKey) |
| |
| βββββββββββββββββββββββββββββββββββ |
|βββββββββββ method: "connect" βββ>|
| β params: { β |
| β client: { id, mode }, β |
| β auth: { token }, β |
| β device: { β |
| β id: "<deviceId>", β |
| β publicKey: "<base64url>", β |
| β signature: "<base64url>", β |
| β signedAt: 1707123456789, β |
| β nonce: "<uuid>" β |
| β } β |
| β } β |
| βββββββββββββββββββββββββββββββββββ |
| |
The gateway performs these checks in order:
ββ Gateway: Verify Connect βββββββββββββββββββββββββββββββββββββββββββββ
β β
β 1. IDENTITY CHECK β
β derivedId = SHA-256(device.publicKey) β
β assert derivedId === device.id β
β β Reject: "device identity mismatch" β
β β
β 2. TIMESTAMP CHECK β
β skew = |now - device.signedAt| β
β assert skew < 10 minutes β
β β Reject: "device signature expired" β
β β
β 3. NONCE CHECK (v2 only, non-loopback) β
β assert device.nonce === connectNonce (from challenge) β
β β Reject: "invalid nonce" (prevents replay attacks) β
β β
β 4. SIGNATURE CHECK β
β Rebuild same pipe-delimited payload server-side β
β Ed25519.verify(payload, device.publicKey, device.signature) β
β β Reject: "signature verification failed" β
β β
β 5. SHARED SECRET CHECK β
β safeEqualSecret(auth.token, GATEWAY_TOKEN) β
β Uses constant-time comparison (no timing attacks) β
β β Reject: "token_mismatch" β
β β
β 6. PAIRING CHECK β
β Look up device.id in paired.json β
β If paired: verify device token matches stored token β
β If not paired: create pairing request β
β - Loopback connections: auto-approve (silent) β
β - Remote connections: require admin approval β
β β Reject: "pairing required" β
β β
β 7. ISSUE TOKEN β
β On success: send hello-ok with device token β
β Token = 32 random bytes, base64url-encoded (44 chars) β
β Stored in paired.json under device's role β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
ββ New Device βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β STEP 1: Device connects for the first time β
β β
β Browser ββββ WebSocket ββββ> Gateway β
β (new keypair, no token) "pairing required" ββ> disconnect β
β β
β STEP 2: Pairing request created β
β β
β Gateway stores pending request: β
β { β
β requestId: "<uuid>", β
β deviceId: "<sha256-of-pubkey>", β
β publicKey: "<full-public-key>", β
β platform: "macos", β
β clientId: "control-ui", β
β role: "operator", β
β scopes: ["operator.admin", "operator.approvals", ...], β
β remoteIp: "203.0.113.42", β
β ts: 1707123456789 β
β } β
β TTL: 5 minutes (request expires if not approved) β
β β
β STEP 3: Admin approves via CLI or Control UI β
β β
β $ openclaw devices approve <requestId> β
β β
β Gateway generates role token: β
β token = randomBytes(32).toString("base64url") β
β β
β Writes to paired.json: β
β { β
β "<deviceId>": { β
β deviceId, publicKey, role, roles, scopes, β
β tokens: { β
β "operator": { β
β token: "<44-char-base64url>", β
β role: "operator", β
β scopes: ["operator.admin", ...], β
β createdAtMs: 1707123456789 β
β } β
β } β
β } β
β } β
β β
β STEP 4: Device reconnects, receives token β
β β
β Browser ββββ WebSocket ββββ> Gateway β
β (same keypair, no token) Paired! Issue token in hello-ok β
β β
β Browser stores token in localStorage: β
β key: "openclaw.device.auth.v1" β
β val: { β
β version: 1, β
β deviceId: "<sha256>", β
β tokens: { β
β "operator": { token: "<44-chars>", role, scopes, updatedAtMs } β
β } β
β } β
β β
β STEP 5: Subsequent connections use stored token β
β β
β Browser ββββ WebSocket ββββ> Gateway β
β (keypair + token in payload) Verified! Full access granted β
β β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| What | Where | Format |
|---|---|---|
| Device keypair (browser) | localStorage["openclaw-device-identity-v1"] |
JSON: { deviceId, publicKey, privateKey } |
| Device token (browser) | localStorage["openclaw.device.auth.v1"] |
JSON: { deviceId, tokens: { role: { token } } } |
| Device keypair (CLI/server) | ~/.openclaw/identity/device.json |
JSON, mode 0600 |
| Paired devices (gateway) | ~/.openclaw/devices/paired.json |
JSON object keyed by deviceId |
| Gateway shared secret | OPENCLAW_GATEWAY_TOKEN env var |
64-char hex string |
The dashboard (deploy/openclaw-stack/dashboard/server.mjs) serves browser session UIs (noVNC), media files, and future dashboard features. It enforces two authentication layers:
Browser CF Edge Dashboard Server
| | |
|ββ GET /dashboard/ βββββββ>| |
| | |
| CF Access check: | |
| - IdP login if needed | |
| - Set JWT cookie | |
| - Inject JWT header | |
| | |
| |ββ Forward + JWT βββββββββ>|
| | |
| | LAYER 1: Verify JWT |
| | - Check exp, iss, aud |
| | - Verify RSA-SHA256 sig |
| | - Fetch CF public keys |
| | β fail β 403 |
| | |
| | LAYER 2: Device pairing |
| | - Check session cookie |
| | β no cookie β auth gate|
| | β
valid β serve page |
| | |
|<ββ Dashboard HTML ββββββββ|<ββββββββββββββββββββββββββ|
When a user has no valid session cookie, the dashboard serves an auth gate page that automatically authenticates using the device token stored by the gateway Control UI:
Browser Dashboard Server
| |
|ββ GET /dashboard/ βββββββββββββββββ> |
| |
| No session cookie found |
| |
| <ββββ Auth gate HTML+JS βββββββββββββ|
| |
| JS executes: |
| 1. Read localStorage |
| "openclaw.device.auth.v1" |
| |
| ββ No token found? βββββββββββ |
| β Show "Not Paired" message β |
| β Link to Gateway Control UI β |
| ββββββββββββββββββββββββββββββ |
| |
| ββ Token found? ββββββββββββββ |
| β Extract first role token β |
| β from tokens object β |
| ββββββββββββββββ¬ββββββββββββββ |
| | |
|ββ POST /_auth βββ |
| { deviceId: "6ead...", |
| token: "hM81Mf..." } |
| ββββββ>|
| |
| Validate token against|
| paired.json in-memory |
| cache |
| |
| ββ 403: Token invalid ββββββββ |
| β Show "Device Not β |
| β Recognized" error β<ββββββ|
| ββββββββββββββββββββββββββββββ |
| |
| ββ 200: Token valid ββββββββββ |
| β Set-Cookie: β |
| β openclaw-dashboard= β<ββββββ|
| β <deviceId>.<ts>.<hmac>; β |
| β HttpOnly; SameSite=Strictβ |
| β β |
| β JS reloads page β |
| ββββββββββββββββ¬ββββββββββββββ |
| | |
|ββ GET /dashboard/ (with cookie) ββββ> |
| |
| Cookie verified β
|
| |
| <ββββ Dashboard page ββββββββββββββββ|
The dashboard uses stateless HMAC-signed cookies β no server-side session storage needed.
Cookie format:
<deviceId>.<timestampMs>.<hmac-sha256-hex>
Signing:
HMAC-SHA256(
key: OPENCLAW_GATEWAY_TOKEN,
data: "<deviceId>.<timestampMs>"
) β hex
Verification:
ββ verifySessionCookie(cookieValue) ββββββββββββββββββββββββ
β β
β 1. Split by "." β [deviceId, ts, hmac] β
β Reject if not exactly 3 parts β
β β
β 2. Check expiry β
β elapsed = now - parseInt(ts) β
β Reject if elapsed > SESSION_MAX_AGE (default 24h) β
β Reject if elapsed < 0 (future timestamp) β
β β
β 3. Recompute HMAC β
β expected = HMAC-SHA256(GATEWAY_TOKEN, deviceId.ts) β
β β
β 4. Constant-time compare β
β timingSafeEqual(expected, hmac) β
β Reject if mismatch β
β β
β 5. Return { deviceId } on success β
β β
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
Cookie attributes:
| Attribute | Value | Purpose |
|---|---|---|
HttpOnly |
Yes | Not accessible to JavaScript (XSS protection) |
SameSite |
Strict |
Not sent on cross-origin requests (CSRF protection) |
Path |
/dashboard |
Scoped to dashboard routes only |
Max-Age |
86400 (24h) |
Browser-enforced expiry (server also checks) |
The dashboard watches paired.json for real-time changes when devices are paired or revoked:
Gateway paired.json Dashboard
| | |
| Admin approves device | |
|ββββ Write new entry βββββββββββ> | |
| | |
| |ββββ inotify/poll βββββββββ> |
| | |
| | loadPairedDevices() |
| | - Read file |
| | - Compare content hash |
| | - Parse if changed |
| | - Build Map<id, tokens> |
| | |
| Admin revokes device | |
|ββββ Remove entry ββββββββββββββ> | |
| | |
| |ββββ inotify/poll βββββββββ> |
| | |
| | Device removed from map |
| | New auth attempts fail |
| | (existing cookies valid |
| | until expiry β CF |
| | Access is the primary |
| | perimeter) |
Two watchers for reliability:
fs.watch()β inotify-based, immediate but may miss events on some filesystemsfs.watchFile()β stat-based polling every 5 seconds, always reliable- Both debounced at 500ms to coalesce rapid writes
If OPENCLAW_GATEWAY_TOKEN is not set, the entire device pairing auth layer is disabled:
OPENCLAW_GATEWAY_TOKEN="" β PAIRING_AUTH_ENABLED = false
- No /_auth routes registered
- No session cookie checks
- No paired.json watching
- Dashboard protected by CF Access JWT only
- Log: "[dashboard] OPENCLAW_GATEWAY_TOKEN not set β device pairing auth disabled"
noVNC browser sessions use WebSocket connections that are also protected by both auth layers:
Browser Dashboard Server
| |
|ββ WS Upgrade /dashboard/browser/<agent>/websockify ββ>
| |
| 1. Verify CF Access JWT header |
| β β socket.destroy() |
| |
| 2. Verify session cookie |
| (from Cookie header on upgrade) |
| β β socket.destroy() |
| |
| 3. Look up agent in browsers.json |
| β β socket.destroy() |
| |
| 4. TCP connect to noVNC container |
| β β socket.destroy() |
| |
|<ββ Bidirectional pipe βββββββββββββββ>|βββ> noVNC container
| User | UID | SSH | Sudo | Purpose |
|---|---|---|---|---|
adminclaw |
1001 | Key-only, port 222 | Passwordless | System administration |
openclaw |
1002 | None | None | Application runtime |
If openclaw is compromised, the attacker cannot escalate to root. All Docker commands run as openclaw; system administration requires adminclaw.
ββ VPS Host βββββββββββββββββββββββββββββββββββββββββββββββββββ
β β
β adminclaw (admin) openclaw (runtime) β
β | | β
β | βββββββ΄βββββββββββ β
β | β Sysbox Runtime β β
β | β (uid remapping)β β
β | βββββββ¬βββββββββββ β
β | | β
β | βββββββββββ΄βββββββββββββββ β
β | β Gateway Container β β
β | β (root inside = uid β β
β | β 1002 on host via β β
β | β Sysbox remap) β β
β | β β β
β | β ββββββββββββββββββββ β β
β | β β Nested Docker β β β
β | β β β β β
β | β β ββββββββββββββββ β β β
β | β β β Sandbox β β β β
β | β β β - read-only β β β β
β | β β β - cap DROP β β β β
β | β β β - no network β β β β
β | β β β - tmpfs home β β β β
β | β β ββββββββββββββββ β β β
β | β β ββββββββββββββββ β β β
β | β β β Browser β β β β
β | β β β Sandbox β β β β
β | β β β - bridge net β β β β
β | β β ββββββββββββββββ β β β
β | β ββββββββββββββββββββ β β
β | ββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
| Property | Setting | Purpose |
|---|---|---|
| Filesystem | readOnlyRoot: true |
Prevents persistent malware |
| Home directory | tmpfs (ephemeral) |
No persistent state |
| Capabilities | capDrop: ["ALL"] |
Minimal Linux privileges |
| Network | none (default) |
No outbound internet access |
| Network (browser) | bridge (per-agent override) |
Only agents needing CDP/internet |
| Purpose | Algorithm | Key Size | Where |
|---|---|---|---|
| Device identity | Ed25519 | 256-bit | Browser localStorage, server device.json |
| DeviceId derivation | SHA-256 | 256-bit | Hash of Ed25519 public key |
| Challenge nonce | UUID v4 | 128-bit | Generated per WebSocket connection |
| Device token | randomBytes |
256-bit (32 bytes) | paired.json, base64url-encoded |
| Gateway shared secret | Hex string | 256-bit (32 bytes) | .env / openclaw.json |
| Dashboard session cookie | HMAC-SHA256 | 256-bit key | Keyed on GATEWAY_TOKEN |
| CF Access JWT | RSA-SHA256 | 2048+ bit | Cloudflare-managed keys |
| Secret comparison | Constant-time | N/A | timingSafeEqual / safeEqualSecret |
- Browser sessions: Live noVNC streams of agent browser activity
- Media files: Screenshots, PDFs, downloads generated by agents
- Gateway control: Agent management, configuration, chat sessions
- Agent sandboxes: Isolated execution environments with tool access
| Vector | Mitigation |
|---|---|
| Direct port scanning | Cloudflare Tunnel (no exposed ports) |
| Stolen CF Access credentials | Device pairing required (second factor) |
| XSS on gateway domain | HttpOnly + SameSite=Strict cookies |
| Replay attacks | Nonce-based challenge (v2 protocol) + 10-min timestamp skew |
| Timing attacks on secrets | timingSafeEqual for all comparisons |
| Brute force pairing tokens | 256-bit random tokens (2^256 search space) |
| Compromised sandbox | Read-only filesystem, dropped capabilities, network isolation |
Compromised openclaw user |
No sudo, no SSH β cannot escalate to root |