Skip to content
Subnomic
← Back to blog
Sites Oct 22, 2026 · 3 min read

A deploy key needs two scopes, not one

Our docs said a pipeline needed one scope. A key made that way published previews, then got a 403 the first time it tried to go live. Here is why, and what changed.

Until this week the Sites docs told you to give a pipeline one scope, sites.deploy. It sounded right — deploying is what a pipeline does — and it was half right. A key with only that scope publishes previews all day long. The first time it runs subnomic sites deploy --prod, it gets a 403.

The docs now ask for two scopes, sites.read and sites.deploy. This is why the second one is needed, and why the obvious fix — letting deploy imply read — is not the one we took.

A preview only writes

A preview is three steps, all of them sites.deploy: open a deployment, upload the files the workspace does not already hold, and seal it. Nothing is read back, so a key that can only deploy can do all of it, and the address of that build comes back on stdout.

Going live reads first

Promoting a deployment moves the site’s live pointer, and moving a pointer safely means knowing where it points now. So before it promotes, the CLI reads the site, notes which deployment is live, and sends that along with the request: make this one live, if that one still is.

That condition is the point. Picture an incident: somebody rolls the site back by hand while a slow pipeline is still uploading the broken build. Without the condition, the pipeline finishes, promotes, and silently undoes the rollback. With it, the promotion is refused with 409 and sites.deployments.error.changed_underneath, and the rollback stands.

The read that makes this work is GET /sites/:id, and reading a site is sites.read. subnomic sites rollback reads too — it lists the deployments to find the one before the live one — and so does --site <address>, which has to look an address up before it can use it.

CommandScopes it needs
subnomic sites deploysites.deploy
subnomic sites deploy --prodsites.read, sites.deploy
subnomic sites rollbacksites.read, sites.deploy
subnomic sites deploy --site <address>sites.read, sites.deploy
subnomic sites createsites.write

Why deploy does not include read

Permissions in Subnomic are exact. A key holds the scopes it was given — never more than the person who made it — and every check asks for one permission by name. Nothing implies anything: deploy does not bring read with it, and neither does write.

That keeps a key’s power readable from its list of scopes, which is the reason to scope it at all. So instead of quietly widening sites.deploy, the docs ask for the pair. It still cannot create a site, delete one, attach a domain or change who may visit it: all of that is sites.write.

Keeping the docs honest

The mistake was a sentence nothing checked. Now a test reads the permission of every route the CLI calls for a production deploy and a rollback — from the route definitions themselves, in both services a deploy touches — and fails unless the documented key is exactly what those calls need, cannot reach a destructive route, and is the one the docs name. If either side changes, the build says so before a pipeline does.

If your pipeline stops at --prod

Create a workspace API key with sites.read and sites.deploy, put it in SUBNOMIC_TOKEN, and revoke the old one. The whole workflow — a preview per pull request, production on main, a rollback button — is in Deploy from GitHub Actions. How we think about narrow credentials in general is in Designing least-privilege access that people actually use.