Security and access
This page covers how people get into the console and what each of them may do in a project. It also covers how keys and secrets are kept, and the checks every outgoing call passes.
Apps use project API keys, not sign-in. What an app can do with one is on For apps.
The way in
By invitation only
A session lasts
12 h idle, 7 days at most
In each project
Admin, or 8 permissions
Project secrets
Sealed, shown once
Who gets in
There is no public sign-up and no allow list. An admin invites each person. After that, people sign in with Google or with a passkey. One local account, the break-glass admin, signs in with a password and a code.
An invitation
-
An admin invites an emailWith a role. A member also gets their permissions per project. Only admins may invite.Mail is off: there are no invitations (503).
-
The console mails a link
<console URL>/invite?token=…. The token is 32 random bytes, and only its SHA-256 is kept. The link is valid for 7 days and works once. Admins can list, resend and cancel invitations. A resent link replaces the old one, which stops working.An old, used or cancelled link no longer works. -
The person signs in with Google from the linkThe token travels in the sealed cookie with the OAuth
state. The Google account must have exactly the invited, verified email.Another Google account is refused. The invitation stays open for the right one. -
The person is madeWith the invited role and permissions. Every step of an invitation is written to the audit log.In. From now on, Google lets this person in by their Google user id.
Sign in with Google
- This is the main way in. It uses the OpenID Connect authorization-code flow with PKCE, a
statein a sealed, short-lived cookie, and anonce. - The ID token's issuer, audience, expiry, nonce and
email_verifiedare checked against Google's published keys. - Only Google's user id (
sub) is kept, never Google's tokens. Google's own two-step protects the account. - Google lets in only people who are already here and not disabled. Anyone else is refused as not invited.
The break-glass admin
- This is the one local account, named by the
ADMIN_EMAILsetting. It is a full admin and, for now, the owner's main account. No admin may demote, disable or remove it. - It is the only account with a password, and Google never reaches it. Its sign-in sits behind a separate link.
- The password has at least 16 characters. It is hashed with PBKDF2-SHA-256: a salt per user, 100,000 iterations, over the password peppered with
PASS_SALT. - Two-step is required. Until an authenticator is enrolled, the right password gets no session, only an enrolment. A code from the new authenticator saves its secret (sealed), shows ten backup codes once, and starts the session.
- The password and the code are sent together, so no half-signed-in state is kept. Each code works once.
- Five wrong tries from one address lock that address out for 15 minutes. Fifty from all addresses together lock the account for 15 minutes. A locked account answers exactly as a wrong password does.
Forgot password
For the break-glass admin only.
- A request answers the same for any address. Only after answering does the console look the address up and mail the link. Neither the answer nor its timing tells whose address it is.
- The link holds 32 random bytes, of which only the SHA-256 is kept. It is valid for 30 minutes and good for one try. While it is unused and unexpired, another request mails nothing.
- At most 5 new links an hour for the account, and 10 requests per address in 15 minutes.
- The new password has 16 characters or more. Once an authenticator is enrolled, setting it also needs a current code or an unused backup code. An email alone never takes the account.
- A reset signs out every session, removes the account's passkeys, clears the lockout and is audited. It also mails a notice that the password was changed.
Passkeys
- Any signed-in person may add up to 10 passkeys and sign in with one instead of Google. They use WebAuthn on WebCrypto, with no library.
- User verification is required, so a passkey sign-in counts as both factors. ES256 and RS256 keys are taken. With attestation set to
none, the console trusts the key, not its maker. - Adding or removing a passkey needs a session started in the last 15 minutes. Otherwise the answer is 403
reauth. - A passkey works only within 30 days of a sign-in with Google or the password. So suspending someone's Google account still ends their access within 30 days.
- The challenge is 32 random bytes, good for 2 minutes and once. It travels sealed in the
__Host-passkeycookie, bound to its use. - Checks: the challenge, the console's exact origin, the RP ID, user presence and verification, and at sign-in the signature and the user handle.
- A signature counter that goes back removes the passkey and mails its owner.
- Every add and remove is mailed. Signing out everywhere keeps passkeys; a password reset removes them. The break-glass admin keeps its password and authenticator as the backup.
Sessions and CSRF
| Part | How it works |
|---|---|
| Session token | Random. Only its HMAC is stored. |
| Cookie | A __Host- cookie, HttpOnly, Secure and SameSite=Strict. |
| How long | 12 hours idle, 7 days at most. |
| CSRF token | The HMAC of the session token, so nothing more is stored. Pages read it from GET /api/me and send it as X-CSRF-Token with every change (POST, PUT, PATCH and DELETE). |
| Origin | A change sent from another site's page (its Origin) is refused with 403. |
| Signing out | Sign out, or sign out on every device. Disabling or removing someone signs them out everywhere. A demoted admin is signed out. |
| Answers | JSON, never cached (cache-control: no-store), with the security headers. |
A change from a console page
Each step runs only if the one before it passed. Beside each step is what happens when that step refuses.
-
The originA change must come from the console's own pages.403 for another site's page.
-
The sessionThe session cookie, still within its idle and total time.401: sign in first.
-
The CSRF token
X-CSRF-Tokenmust belong to this session.403. -
The roleAn admin-only route needs the admin role.403.
-
The person's change budgetThe change counts one of the person's 120 changes this hour.429, and nothing is written.
-
The permission in the project
projectFor(), and socan(), runs first on every project route. Every lookup is scoped by the project, so an id or name from another project is never reached.404 when the person may not read the project; 403 for a missing permission. -
The changeThe route runs, and writes its audit line in the same database batch.Done.
Roles and permissions
Admin
- Everything, on every project.
- Only admins may invite people, manage users (role, permissions, disable, remove), make projects, set a project's daily quota and read the audit log.
- An admin may make a member an admin, or the other way round. Both are audited.
- No admin may change their own role, or disable or remove themself. No admin may do any of that to the break-glass admin.
Member
- Per project, any combination of the eight permissions below, ticked one by one. Any permission includes
read. - A project a member has no permission on does not exist for them (404). A route they may see but not use answers 403.
- A grant on all projects (
project: "*") covers every project, including ones made later. It adds up with a project's own grant.
| Permission | Allows | In the presets |
|---|---|---|
read | See the project, its queues, tasks, jobs, logs, secret names and service accounts. | Viewer, Editor, Manager |
create | Create queues, tasks and jobs. | Editor, Manager |
update | Edit queues and jobs. The project's alerts and quiet hours use it too. | Editor, Manager |
delete | Delete queues, tasks and jobs, and purge a queue. | Manager |
pause | Pause and resume queues and jobs, and switch the project's maintenance on and off. | Editor, Manager |
run | Force a job to run, run a task now, and retry a failed task. | Editor, Manager |
keys | Manage the project's API keys. | Manager |
secrets | Manage the project's secrets and service accounts, and where each may be sent. | Manager |
- Presets only fill the boxes. What is stored is the plain list: Viewer is
read; Editor isread,create,update,pauseandrun; Manager is all eight. - One check.
can(user, project, permission)insrc/auth/access.tssits behind every project route. - The pages decide nothing. Each row the API answers carries its
allowedActions, and a page shows a button only when its action is listed. - API keys are not people. A key may do everything in its own project, through
/v1only.
API keys
- Per project. A key is made on the Keys page, with the
keyspermission. It looks likecck_<43 characters>. - Shown once. The key appears only when it is made. It is stored as an HMAC, so it cannot be read back. The Keys page shows when each key was last used.
- Revocable at any time. Revoking is never held back by the person's change budget. A revoked key stays listed, for the record.
- Expiry. A key may get an end date when it is made: more than 1 hour and at most 3 years ahead. Warnings go to the project's alert channels 7 days and 1 day before. After the end,
/v1answers 401This API key has expired. - Rotate makes the replacement key. The old key keeps working for 7 days by default, or until an earlier end that is chosen or already set. It can be revoked before then.
- Its own project only. A key used on another project's path gets 403. It reaches only the
/v1routes. - A key's status reads Active, Expired or Revoked. A project has at most 20 keys that are not revoked.
Secrets and service accounts
Project secrets
- Made on the Secrets page with the
secretspermission, and named in a task or job by name only. The kinds arehmac,basic,bearer,oauth_client, andchat_webhookfor Google Chat alerts. - Sealed at rest with AES-GCM under
SEAL_KEY. Each sealed value is bound to its row. - Shown once, then only by name. An hmac signing secret is made by the console and shown once. A secret is opened only at the moment of a call.
- Never shown again. No secret appears in an answer, a log line, an attempt, an alert or an error. Neither do OAuth token answers, the auth headers sent, or a request body.
- A task's headers and body are sealed while it waits, and dropped once it is done.
- A secret may have an end date and a Rotate, as keys do. Past its end it sends nothing, and the call fails as
auth. - A secret that a job uses cannot be deleted (409). A project has at most 200 secrets and 100 service accounts.
Allowed origins
- Every
basic,bearerandoauth_clientsecret, and every service account, lists thehttps://origins it may be sent to. The list holds 1 to 20 origins, each a scheme, host and port only. Only thesecretspermission sets and changes it. - Checked twice. A task or job that names a credential for any other origin is refused when it is saved (400 at
url). The check runs again before every call. If it fails, the attempt fails asauthand nothing is sent. - So the
createpermission alone, or an API key, never sends a project's credentials to a host of its choosing. - An OIDC audience must be on the service account's list, at the target URL's own origin.
- An OAuth token URL must pass the target rules below when the secret is made. It must pass them again, with the DNS check, at every token fetch.
- Basic auth, Bearer tokens and OAuth are refused on plain
http://targets, where the credentials would travel in clear.
The six auth options for calls
They are the same for tasks and scheduler jobs. A missing auth means none.
| Option | What the console sends | What the target checks | What it holds |
|---|---|---|---|
| 1. None | Nothing | Nothing (public endpoints) | Nothing |
| 2. HMAC | X-Console-Timestamp, and X-Console-Signature: v1=<hex HMAC-SHA256> over timestamp.METHOD.url.body | It works out the HMAC again with the shared secret, and rejects a timestamp older than 5 minutes | A signing secret, made by the console and shown once |
| 3. Basic auth | Authorization: Basic base64(user:password) | Its usual Basic auth | Username and password (sealed); https only |
| 4. Bearer token | Authorization: Bearer <token>, a fixed token pasted in | Its own token check | The token (sealed); https only |
| 5. OIDC token | Authorization: Bearer <JWT>, signed ES256 by the console, valid 5 minutes | The signature against the console's public keys, the issuer, the audience, the expiry and the service account. No shared secret. | A service account and an audience |
| 6. OAuth token | Authorization: Bearer <access token>, fetched by the console from the provider (client credentials) | The provider's own token check | Token URL (https), client ID, client secret (sealed), scope; optional audience or resource |
- Cached OAuth tokens are sealed, and fetched again shortly before they expire. Token answers are never logged.
- After an attempt, the console keeps only a few things: the status, the duration, the content type, the first 1 KB of the body and the first 300 characters of an error. Before they are kept, every credential the attempt sent is replaced by
[redacted]. An attempt that sent a Basic, Bearer or OAuth credential keeps no body at all.
OIDC tokens and the published signing keys
- Service accounts belong to one project and are named
<name>@<project>.cloud-console. They are made on the console and can be disabled. A disabled account fails the attempt asauth. - The signing key is an ES256 key pair the console makes itself. The private key is sealed in the database.
- Rotation every 90 days. The next key appears in the JWKS a day before it first signs. A retired key stays an hour, because its tokens live 5 minutes, and is then deleted.
- The public keys are at
/.well-known/jwks.jsonand the discovery document at/.well-known/openid-configuration. A verifier may cache both for an hour. - The JWKS lists only keys the console can sign with.
- How a target checks a token is on For apps.
Safety of outgoing calls
Tasks, jobs and uptime checks all make their calls through one function, callTarget. The URL rules run when a task or job is saved, as a field error. They run again at each attempt.
-
The URLOnly
http://orhttps://, at most 2,048 characters. Nouser:password@and no#fragment.400 when saved, aturl. -
The host name
localhost,*.localhost,*.local,*.internal,*.home.arpaand the console's own host are refused. So is an IP literal in a private, loopback, link-local, metadata, CGNAT, reserved, documentation or multicast range, or an IPv6 form of one. Examples: 10/8, 172.16/12, 192.168/16, 127/8, 169.254/16, ::1 and fc00::/7. In IPv6, only global unicast (2000::/3) is called.400 when saved. At an attempt, it fails asblocked. -
DNS, before each attemptThe host is looked up with DNS over HTTPS, for both A and AAAA records. If any address it gives is refused, the attempt is too.The attempt fails as
blocked. -
The credentialThe auth option's secret or service account must allow the URL's origin, and must not be expired or disabled.The attempt fails as
auth, and nothing is sent. -
The callRedirects are not followed, so a 3xx is a failure. Each attempt has a deadline: 3 minutes by default, and at most 15 minutes.No answer in time: the attempt fails as
timeout. -
The answerAt most 1 MB is read and 1 KB is kept, with credentials redacted first. Only a 2xx succeeds.2xx: done. Any other result is retried by the queue's or the job's settings.
A failing target never makes the console raise alerts about itself in a loop. Failures are rows and log lines only.
Header rules
- Names are letters, digits,
-and_, at most 100 characters. - Values hold no CR, LF or other control character. A tab is allowed.
- At most 50 headers, of 8 KB each.
- No one can set
Authorization; secrets come only through an auth option, by name. NorHost,Content-Length,Transfer-Encoding,Connection,Keep-Alive,TE,Trailer,Upgrade,Expect,Proxy-*,Sec-*,CF-*orX-Console-*.
Size caps
- A request body: 64 KB, or 256 KB to create a task (larger: 413). It is read as it arrives and refused once it passes the cap.
- A task's body: 100 KB.
- A URL: 2,048 characters. A query parameter: 200 characters.
- Of a target's answer: 1 MB read, 1 KB kept for 7 days.
The pages
- Text only. Text goes onto a page only through
textContent, by a smallh()helper. ESLint refusesinnerHTML,outerHTML,insertAdjacentHTML,srcdoc,document.write,evalandnew Functionin the pages. - The strict CSP below, on every answer and every page. There is no inline script or style, and no third-party script.
- Trusted Types. The pages make one named policy, so script that got in could make no other.
- Nothing extra shipped. The production bundle has no source maps, and there is no single-page fallback. An unknown path gets the Worker's JSON 404.
- Thin pages. Static TypeScript, HTML and CSS, with no framework and no runtime dependency. Validation, defaults, labels and permissions all belong to the Worker.
default-src 'self';
script-src 'self';
style-src 'self';
img-src 'self' data:;
connect-src 'self';
object-src 'none';
base-uri 'none';
form-action 'self';
frame-ancestors 'none';
require-trusted-types-for 'script';
trusted-types service-worker
Per-person budgets and rate limits
Reads and changes are capped per person per hour, across every project. API keys and sign-in have limits of their own. Past a limit the answer is 429, and nothing is done.
| For | What | Limit |
|---|---|---|
| Each person | Reads of queues and jobs, counted in the rows a page may read | 50,000 rows an hour |
| Each person | The object requests behind those reads | 1,000 an hour |
| Each person | Changes (POST, PUT, PATCH, DELETE), admin routes included | 120 an hour. Four are free: sign out, sign out everywhere, remove a passkey, revoke a key |
| Each person | A file of tasks | One change per 10 tasks, so at most 1,200 tasks an hour |
| Each person | Settings exports and imports | 20 an hour |
| Each project | Test alerts | 10 an hour |
| Each project | Task creations, job runs and retries | 5,000 a UTC day by default; an admin sets it |
| Each API key | /v1 requests | 600 a minute |
| Each API key | Task reads, deletes and repeated names | 600 an hour |
| Each address | Break-glass sign-in and enrolment | 10 tries in 15 minutes |
| Each address | Google's callback; passkey sign-in | 20 in 15 minutes each |
| Each address | Forgot password; reset | 10 in 15 minutes each |
| Each address | The other open routes: /health, /.well-known/*, GET /auth/options and GET /auth/google | 120 a minute together |
An address is the caller's IPv4 address, or the /64 of its IPv6 address.
Audit log
Every change a person makes writes an audit line in the same database batch as the change. Some changes are held by a queue's or a job's object: a task's create, run, retry or delete, a purge, and a job's run. Their line is written right after the object answers.
Each line holds the time, who acted, the project, the action, its target and a detail of what changed. Who acted is a person's email, key:<name> for an API key, or system. The detail never holds a secret. Lines are kept for 400 days, and only admins may read them.
| Area | What is recorded |
|---|---|
| Sign-in | Sign-ins (auth.sign_in, with method: "passkey" for a passkey), lockouts (auth.locked), reset links (auth.reset_link) and password resets. |
| Passkeys | passkey.add, passkey.rename, passkey.remove, and passkey.clone_suspected when a counter goes back. |
| People | Each step of an invitation, role changes both ways, access changes, and disabling and removing people. |
| Keys and secrets | key.create, key.rotate, a key's revoke, secret.create, secret.rotate, secret.update and service_account.update. |
| Queues, tasks, jobs | queue.*, task.*, tasks.create (one line for a file of tasks), tasks.retry_failed, tasks.delete_failed and job.*. |
| Project settings | alerts.update, alerts.test, quota.set, maintenance.on and maintenance.off (a person's or a key's), quiet_hours.update, summary.update, settings.export and settings.import. |
| The system | signing_key.create, signing_key.replace and project.seed. |
| Not recorded | Tasks created or deleted through /v1. They are the apps' traffic, and the run logs and the quota count keep them. |
Settings and keys
Three settings protect what the console keeps. They are Worker secrets, pushed by the owner's deploy script from its allow-list. wrangler.toml and the repo hold none of them, and tests use made-up values. Only their names and purposes are given here.
| Name | What it does | Its form | If it is lost or changed |
|---|---|---|---|
SEAL_KEY | Seals every sealed column but the authenticator's: signing keys, project secrets, Google Chat webhooks, cached OAuth tokens, and the headers and bodies of jobs and waiting tasks. It also seals the sign-in and invitation cookies, and keys every HMAC (sessions, CSRF, API keys, backup codes). Two keys are derived from it with HKDF. | 32 random bytes, base64 | Every session, API key and backup code stops working. Project secrets and webhooks are entered again, and OAuth tokens are fetched again. Jobs and waiting tasks fail until they are saved again. The signing key replaces itself. |
MFA_KEY | Seals only the two-step secrets: the break-glass admin's authenticator secret and the pending enrolment cookie. It has its own HKDF info, so neither key opens the other's values. | A random string of at least 32 characters | Every authenticator is enrolled again. |
PASS_SALT | Peppers the break-glass password: PBKDF2 runs over HMAC-SHA-256(PASS_SALT, password). | A random string of at least 16 characters | The stored password no longer matches. The admin sets it again with forgot password. |
Cloud Console, as it is on main. 22 Oct 2026.