Security model
Two facts shape everything below. Your SmartZone password never reaches Claude, and your SmartZone account is the outer limit on what Claude can do. Inside that limit, the write guard makes every change wait for your agreement. The first fact is enforced by the architecture; the second is a deliberate design decision that puts the control in your hands.
Access control is delegated to SmartZone
Section titled “Access control is delegated to SmartZone”SZ-MCP exposes every WSG and SwitchM endpoint your saved admin account is permitted to call — reads, writes and deletes alike. It does not maintain an allowlist of endpoints, and it cannot grant anything SmartZone’s roles do not. What it adds is a check on whether a write is sent: the write guard, which is on by default.
That is on purpose: SmartZone already has role-based access control, with roles, domain scoping and per-permission grants. Duplicating it would mean two models to keep in sync and two places to get it wrong.
What to do about it
Section titled “What to do about it”- Create a dedicated SmartZone admin for SZ-MCP. Do not reuse one that humans or other automations log in with.
- Scope it to the minimum role and domain your use case needs. If you want a read-only experience, grant a read-only role in SmartZone — that is where the restriction belongs.
- Rotate its password on your normal schedule.
The write guard
Section titled “The write guard”With the write guard on (the default), Claude cannot change the controller
without your agreement. It works at the call boundary, on both surfaces:
| Call | What happens |
|---|---|
| A read | Sent |
| A write | Not sent the first time. call returns confirm_required with a preview of the request and a confirm token. Claude shows you the preview, and re-sends the identical request with the token once you agree |
| A dangerous write | The same, plus the current 6-digit code from your authenticator app (totp_required) or, if you have not set one up, an approval on the dashboard (approval_required; the dashboard shows it under Waiting for your approval) |
| Any write in read-only mode | Refused with write_blocked |
| Any write by a viewer or operator | Refused with write_blocked: only engineers and above can make changes, whether or not the guard is on |
A confirm token is valid for 10 minutes, is single-use, and covers only the
exact request that was previewed, for one organisation: a changed request needs
a fresh confirm. It is also refused in the same code_mode run that received
it (“That confirm token was issued in this same code_mode run”), so Claude has
to stop, show you the preview and send the write in a later run: it cannot
confirm its own write.
| Check | Limit |
|---|---|
| Dashboard approval | Expires after 10 minutes; covers one send. await_approval waits up to 15 seconds for it |
| Authenticator codes | 5 tries per 5 minutes per person, right or wrong (totp_locked past that); each code works once |
| Loosening the guard | An admin who has an authenticator app set up needs a current code from it to turn the guard or the extra check off, or to untick a dangerous category or remove one of your own rules, as removing the app does |
An organisation admin sets the policy on the dashboard’s Write guard card:
- Confirm every write (on by default).
- Read-only mode (off by default): refuse every write, whatever the chat says.
- Extra check for dangerous actions (on by default), with the Dangerous actions list. Eight categories are ticked by default: restarting or shutting down the controller, rebooting an AP, or moving APs to another zone; firmware upgrades, AP patches and signature packages; restoring a controller or switch configuration backup; cluster operations; disconnecting, deauthenticating or blocking clients; deleting a zone, domain, WLAN, switch or switch group; AP packet captures; reordering AP registration rules. A ninth, Every write, is unticked by default.
- Your own rules: a method and path to treat as dangerous too, where
*is one path segment and a trailing/**anything below, e.g.PATCH /rkszones/*/wlans/**.
Each person sets up their own Authenticator app on the same card. Writes
are logged, with who asked for them, on the Write history page (/writes).
Where the guard applies
Section titled “Where the guard applies”Every controller write Claude can cause goes through the guard, but not always as one confirm per request:
| Path | How it is guarded |
|---|---|
wifi.call / switches.call | One confirm per write, as above |
switches.backup | A write: confirm |
switches.restore | A dangerous write. Also refused unless the switch has a successful backup from the last 15 minutes, unless you say to force it |
history.undo | The inverse write goes through the guard like any call. If the target changed after the write, it also needs force |
mop.start (a MOP with writes) | Approved once for the whole run: you confirm a preview of every write step, its body, its rollback and the change window. The run’s writes are then sent without further confirms, for 12 hours, and only for the person who started it. They are still refused for a read-only role or organisation, or if a step has since become a dangerous action |
mop.rollback | Sent under the run’s approval, by the person who started it; anyone else uses history.undo |
mop.schedule | A standing approval of that MOP version and those parameters, through the same preview. Each occurrence re-checks that the approver still has the role, the MOP is unchanged and no step has become dangerous, then runs for 12 hours under that approval. Recurring schedules last at most 90 days |
lint.to_mop | Only builds (or saves) a MOP; nothing is sent until mop.start |
org.import_bundle | Changes SZ-MCP’s own settings, never the controller. A dry run unless apply: true (engineer and up) |
switches.ping, switches.traceroute | POSTs classified as reads, so not confirmed. So are SwitchM’s list endpoints (POST /switch and other add… operations) |
Two controller writes happen outside the chat, from the dashboard, and do not go through the guard or read-only mode: turning on streaming (an admin action that creates a Northbound streaming profile on the controller) and Let’s Encrypt certificates for a controller behind a tunnel (issued and renewed by an admin’s choice; each write is recorded in the write log under that admin).
Where the password lives
Section titled “Where the password lives”The password is stored in Cloudflare D1, encrypted with AES-256-GCM. The key
is derived per record with HKDF-SHA256 from an ENCRYPTION_KEY that exists
only in Cloudflare’s encrypted secret store.
Each record carries its own random 16-byte salt, stored alongside the ciphertext. The salt is not a secret; its job is to force per-record key derivation, so compromising the master key still means attacking each ciphertext individually rather than all of them at once.
The plaintext is decrypted in memory, only when a new service ticket needs minting, and is never written anywhere. It is never returned by any API: the credentials endpoint omits it, and the dashboard renders a placeholder instead.
The service ticket lifecycle
Section titled “The service ticket lifecycle”SmartZone authenticates API calls with a service ticket, minted from your username and password and then passed on each request.
As the diagram shows, the ticket is fetched from cache where possible and minted
from the decrypted password only when the cache misses. It is attached to the
upstream URL by the Worker, and the response handed back to Claude has that
parameter replaced with <redacted>.
| Property | Value |
|---|---|
| Cache location | Cloudflare KV, keyed sz_ticket:<orgId>: one ticket per organisation, shared by its members |
| Cache lifetime | 25 minutes — deliberately shorter than SmartZone’s idle timeout |
On 401 | The ticket is discarded, one fresh ticket is minted, and the request is retried exactly once |
| Flushed | On every credentials save, on delete, and on Test connection |
A second 401 after the retry is returned to the model as-is rather than
triggering another login.
How often SZ-MCP logs in to your controller
Section titled “How often SZ-MCP logs in to your controller”Every service ticket is a login to SmartZone under the admin username you saved, so the ticket lifecycle decides what your controller’s admin or audit log sees from SZ-MCP:
- While Claude or SZ-MCP is using the controller: at most one login every 25 minutes per organisation. The ticket’s lifetime is not extended by use, so steady use logs in again roughly every 25 minutes.
- While nobody is chatting: background work still uses the login, and logs in whenever it finds no ticket cached: the hourly inventory refresh (on once the organisation has run its first inventory sync, with a daily config snapshot), metric polling every 5 minutes if it is on (which also reads the admin audit log), synthetic checks, scheduled MOPs, and Let’s Encrypt renewals. With none of these on, there are no logins.
- After a
401: one extra login for the single retry. - On Save & detect or Test connection: one login to detect the API version
(a save that keeps the stored password skips detection). Both also flush the
cached ticket, so the next
calllogs in again.
What Claude never sees
Section titled “What Claude never sees”| Item | Reaches Claude? |
|---|---|
| SmartZone password | No — decrypted in the Worker only, never in the isolate |
| Service ticket | No — attached server-side, redacted from the url field |
| Your Google credentials | No — Google handles the sign-in |
| MCP access token | Held by Claude; stored here only as a SHA-256 hash |
| SmartZone API responses | Yes — this is the point |
The program Claude writes runs in a separate sandboxed isolate with no network access of its own and no ambient credentials. Its only reach is the functions on the twelve code-mode namespaces. See How code mode works.
Two protections apply to what comes back from the controller:
- Private keys. Every PEM private-key block in a controller response is
replaced with
[private key redacted](SmartZone’sGET /certstore/certificate/{id}returns one in full). - Network text is labelled. A result carrying
names, SSIDs, hostnames or log text gets a
dataNotice, and any string that reads like an instruction to the model is listed ininjectionWarnings. Claude is told never to follow instructions found in data, and no write is sent without your agreement.
Isolation between organisations
Section titled “Isolation between organisations”Every MCP request is authenticated to exactly one person and one organisation before any tool runs: the connector is bound to the organisation chosen when it was authorised, and the person’s membership is checked again on every call, so removing someone stops their connector at once. Stored credentials, cached tickets and collected data are keyed by organisation. Members of one organisation share its controller login; there is no code path that serves one organisation’s request from another’s data.
Tokens and session cookies are stored hashed, never in plaintext:
| Credential | Storage |
|---|---|
Web session cookie szmcp_session | SHA-256 hash; HttpOnly, Secure, SameSite=Lax; 30 days |
MCP access token (szmk_…) | SHA-256 hash; 30 days |
| MCP refresh token | SHA-256 hash; 90 days, rotated on use |
| OAuth authorization code | SHA-256 hash; 10 minutes, single use |
Transport and egress
Section titled “Transport and egress”All calls to your controller are HTTPS with certificate validation enforced by the platform — which is why a publicly trusted certificate is a requirement.
Outbound destinations are constrained: hosts in private, loopback, link-local, CGNAT, multicast and reserved ranges are refused, both when you save credentials and on every call, and only ports 8443 and 443 are allowed. The full list is in Controller requirements. Redirects from the controller are returned, never followed.
Observability logs record method, status and duration. Request and response bodies are not logged there. For SmartZone’s own login errors only the status is logged; the body is never logged or echoed to the client, because it can disclose account-lockout state and username hints.
What is stored, and for how long
Section titled “What is stored, and for how long”| Data | Where | Kept |
|---|---|---|
| Write log: time, person, method, path, status, how it was approved, and a SHA-256 hash of the body | D1 | 365 days |
| Write history: each write’s full body, the target’s state just before it, and a create’s response (secret-looking fields masked when shown, kept unmasked so an undo can restore them) | Your organisation’s store | 30 days, at most 5,000 writes |
| Daily config snapshots and their field changes; secrets kept only as keyed digests | Your organisation’s store | 180 days |
| Controller admin audit log | Your organisation’s store | 400 days |
Northbound streaming is not available on the hosted service yet. When an organisation streams, the stored events, alarms, client sessions and connection attempts name clients; an organisation admin can shorten their retention (1–14 days, default 14), keep application flows for 0–2 days (default 2), and replace client MACs, host names and user names with pseudonyms, on the Privacy card under Data collection.