Audit log
A workspace’s audit log records every request the edge let through or turned away, and every change anyone made to the workspace. Only the workspace’s owners and admins can read it.
The edge writes each allow before the request reaches your app. If it can’t, it answers 503 and doesn’t pass the request on, so nothing reaches an app without a line in the log. It writes each refusal before it answers too. A page load sent off to sign in isn’t a refusal, so it’s written as the redirect goes out, and a first visit doesn’t wait on the log.
Read the log
Section titled “Read the log”jiayang audit acme --limit 122026-09-23T20:54:18Z plan.limit ana@example.com2026-09-23T20:54:18Z mail.sent ana@example.com2026-09-23T20:54:18Z member.put ana@example.com bo@example.com as member2026-09-23T20:54:18Z plan.limit ana@example.com2026-09-23T20:54:17Z plan.limit ana@example.com2026-09-23T20:54:17Z app.create ana@example.com2026-09-23T20:54:17Z app.create ana@example.com2026-09-23T20:54:17Z app.public_paths ana@example.com2026-09-23T20:54:17Z plan.limit ana@example.com2026-09-23T20:54:17Z token.create ana@example.com hello: bt_vtc7lz30r6658d5i as viewer2026-09-23T20:54:17Z token.create ana@example.com hello: bt_2k9q1m4c8x7wz0ra as viewer2026-09-23T20:54:17Z token.create ana@example.com hello: bt_5p0d3n6f1y8ht2ue as editorEntries come newest first, 50 at a time unless you say otherwise. Times are UTC. Each line is the time, what happened and who did it, then what it was done to: the app, and who got which role, which token, secret or version.
A request reads access allow or access deny, with the reason, the caller, the method and the path. These lines are from the same log:
2026-09-23T20:55:11Z access allow (allowed) - POST /acme/hello/hooks/stripe2026-09-23T20:54:17Z access allow (allowed) bt_vtc7lz30r6658d5i GET /acme/hello/2026-09-23T20:54:17Z access deny (no_grant) lee@example.com GET /acme/hello/2026-09-23T20:54:17Z access allow (allowed) sam@example.com GET /acme/hello/The caller is a person’s email, a bypass token’s id, - when nobody presented anything, as on a public path, or the provider, like webhook:stripe, for a delivery whose signature the platform checked. A request through the apps domain shows its path with the workspace and app in front, like /acme/hello/. The log keeps the path only, never the query string, since query strings can carry secrets.
A person who isn’t an owner or an admin gets:
error: You don't have permission to do that.Options
Section titled “Options”| Flag | What it does |
|---|---|
--app hello |
Only this app. --app acme/hello works too. |
--limit <n> |
How many entries, from 1 to 500. The default is 50. |
--before <time> |
Only entries before this time: a date like 2026-09-01 (its start, in UTC) or an RFC 3339 time. |
--before-id <id> |
With --before, the id of the last entry you already have. |
When a page comes back full, the CLI prints the command for the next one on stderr, so what’s on stdout stays the log:
Older: jiayang audit acme --limit 12 --before 2026-09-23T20:54:17.880016Z --before-id d2a7458a-9c13-4c9f-98a6-85c7a7120dfcRun it to go back a page. The id keeps entries that share a timestamp from being skipped.
--json prints each entry whole:
jiayang --json audit acme --limit 1[ { "id": "a65b120d-1a7a-4f95-9cf2-f858cce9fe85", "at": "2026-09-23T20:54:18.0254Z", "source": "control_plane", "action": "plan.limit", "decision": null, "reason": null, "status": null, "app_id": null, "entry": null, "actor": { "email": "ana@example.com", "kind": "user", "session_id": "cs_o37e9ltkz87iioz3", "sub": "usr_qkm7x7owpb4l0055ls6attmfsm", "via": "cli" }, "host": null, "method": null, "path": null, "detail": { "max": 3, "plan": "free", "what": "members" }, "ingested_at": "2026-09-23T20:54:18.0254Z" }]| Field | What it holds |
|---|---|
source |
edge for a request, control_plane for a change. |
action |
access for a request, otherwise the change, like grant.put. |
decision, reason, status |
For a request: allow or deny, why, and the status the edge answered with. An allow that reaches your app has no status, since your app picks it. Signing in to an app is an allow with reason session_issued and status 302. A page load with no session, sent off to sign in, is a deny with reason sent_to_sign_in, status 302 and an anonymous caller: nothing reached the app, but nobody was refused. |
entry |
For a request: browser for the app’s own address, gateway for the apps domain. |
actor |
Who. kind is user, service, anonymous or webhook. A user has email, and usually via: cli, browser, session, or push_token for an image push. Some entries, such as the mail.sent for an invitation, have no via. A service is a bypass token, with token_id, or the platform itself, with name: billing for billing events such as a plan change from Stripe, janitor for notices mailed to owners. A webhook is a delivery a verifier let through, with its provider, pattern and verifier_id. |
host, method, path |
For a request: where it went. |
detail |
For a change: what changed. grant.put names the email and role, member.remove lists the apps the person lost. For a delivery on a verified path, webhook names the provider, the pattern and the verifier_id, and either the check that failed (failure) or which secret matched (matched) with the provider’s signed id (delivery) where it has one. |
ingested_at |
When the entry reached the log. Requests arrive a moment after they happen. |
Follow it live
Section titled “Follow it live”jiayang watch prints the recent entries, then new ones as they arrive. Give it a workspace, or one app:
jiayang watch acme --tail 620:54:17 event ana@example.com (cli) app.create driver=workers slug=three20:54:17 event ana@example.com (cli) plan.limit max=3 plan=free what=apps20:54:18 event ana@example.com (cli) plan.limit max=3 plan=free what=apps20:54:18 event ana@example.com (cli) member.put email=kim@example.com role=member20:54:18 event ana@example.com mail.sent template=invitation_workspace to=kim@example.com20:54:18 event ana@example.com (cli) plan.limit max=3 plan=free what=membersIt prints following acme (times are UTC; Ctrl-C to stop) on stderr and keeps going until you stop it. (cli) marks something done from a CLI session.
--denies shows only refused requests:
jiayang watch acme/hello --denies20:54:17 DENY lee@example.com (cli) GET /acme/hello/ 404 no_grant20:54:17 DENY lee@example.com (cli) GET /acme/hello/ 404 no_grant20:54:17 DENY - GET /acme/hello/ 401 no_credential20:54:17 DENY - GET /acme/hello/ 401 invalid_credential20:54:17 DENY - POST /acme/hello/hooks/stripe 401 no_credential20:54:17 DENY - POST /acme/hello/hooks/other 401 no_credential| Flag | What it does |
|---|---|
--tail <n> |
How many recent entries to print before following. The default is 20. |
--denies |
Only refused requests. |
--seconds <n> |
Stop after this many seconds instead of running until you press Ctrl-C. |
jiayang watch asks for new entries once a second, by when they reached the log. An entry still on its way when it asks can be missed, so use jiayang audit when you need every line.
A live terminal view
Section titled “A live terminal view”jiayang topjiayang top fills the terminal with your workspaces and apps on the left. Select an app to see who has access, its tokens and secrets, a chart of allowed and denied requests, and the stream of requests and changes. The keys are j/k or the arrows to move, f for denies only, r to reload and q to quit. Give it a workspace, jiayang top acme, to open on that one.
In the dashboard
Section titled “In the dashboard”A workspace’s Activity page counts requests allowed and refused, and changes made, over the last hour, 24 hours or 7 days. It charts the traffic, and lists the busiest apps, who’s calling and why requests were refused, above a live stream. Each app’s Activity tab shows the same for that app. Both are for owners and admins only.
The stream asks for new entries every 2 seconds while they’re arriving, and every 15 seconds once three asks in a row have found nothing. A tab in the background asks for nothing, and catches up as soon as you look at it again.
A browser sent to sign in isn’t counted as refused. The stream shows it as Sent to sign in, and leaves it out when you pick Refused.
Why a request was refused
Section titled “Why a request was refused”| Reason | Status | What it means |
|---|---|---|
no_credential |
401 | No session and no token. A browser loading a page is sent to sign in instead, which the log records as sent_to_sign_in. |
sent_to_sign_in |
302 | A page load with no session, sent to sign in and brought back. Not a refusal: nothing reached the app, and the dashboard doesn’t count it as refused. |
invalid_session |
401 | The browser’s session for the app didn’t check out. The edge clears it. |
ambiguous_session |
401 | The browser sent two session cookies for the app’s address, which can mean another app planted one. The edge clears the kind another app could set, and asks for a reload. See Signed in twice. |
invalid_handoff |
401, 405 | A return from sign-in at /.jiayang/session that didn’t add up: no cookie from the redirect that started it, a code already used or made for another app, or a method other than GET. |
invalid_credential |
401 | A token or session that isn’t valid: revoked, expired, signed out or malformed. |
no_grant |
403, 404 | The caller is signed in, but the app isn’t shared with them, or their email domain isn’t allowed. A member of the workspace gets 403. Anyone else gets 404, the answer for an app that isn’t there, so they can’t tell it exists. A bypass token for another app gets 403. |
deleted |
404 | The app’s workspace has been deleted. Answered exactly as no_app is. |
no_app |
404, 401 or 403 | No app answers at that address, or it has nothing deployed. It’s 404 at the app’s own address and for a CLI session. On the apps domain, a request with no token gets 401, and a bypass token gets 403. |
cross_site_request |
403 | A page on another site tried to send a write to the app with the person’s session. |
rate_limited |
429 | Too many requests from one client to public paths, or to sign-in. |
over_quota |
402 | A Free workspace used twice its monthly requests, or reached a monthly limit on database rows. See Limits. |
plan_required |
402 | A container app in a workspace on Free. |
database_full |
507 | The workspace’s databases are over its plan’s limit. Only its owners and admins get in, as themselves. See Limits. |
database_hard |
507 | The databases grew further, or are 10% over the limit. Nobody gets in, owners and admins included. |
rows_unmeasured |
402 | A Free workspace’s database rows couldn’t be measured for two hours, so its apps with a database are paused until they can be. |
suspended |
403 | The workspace is suspended. |
webhook_refused |
401 or 429 | A delivery to a verified public path failed a check, named in detail.webhook.failure. It’s 429 when the workspace’s verified deliveries are over their limit, or the path’s are over its share (rate_limited). See Public paths and webhooks. |
webhook_too_large |
413 | A delivery to a verified public path had a body over 1 MiB. |
access_revoked |
none, or 403 | An open connection was closed because access ended. It’s 403 when access ended while the app was still working on its answer, which then isn’t sent. |
session_expired |
none, or 401 | An open connection was closed because the session behind it ended. It’s 401 when the session ended while the app was still working on its answer, which then isn’t sent. |
control_plane_unavailable |
503 | The edge couldn’t check access, so it refused. |
limiter_unavailable |
503 | The edge couldn’t ask its rate limiter about a request to a public path or a return from sign-in, so it refused rather than let it through with no limit. |
identity_unavailable |
503 | The edge couldn’t sign an identity token for the app, so it didn’t pass the request on. |
config_error |
503 | The edge is set up wrong, or a session from the platform didn’t check out there. Nothing to do with the request: it’s the platform’s to fix. |
Changes you’ll see
Section titled “Changes you’ll see”| Action | When |
|---|---|
workspace.create, workspace.update |
A workspace is made, or its name or allowed domains change. |
member.put, member.remove, member.leave |
Someone is added or given a new role, removed, or leaves. |
app.create, app.update, app.delete |
An app is made, renamed or has its visibility changed, or deleted. app.update says what it was before. |
app.deploy, app.rollback, app.deploy_failed |
A deploy, a rollback to an earlier version, and either one failing. A failure’s reason is refused, with Cloudflare’s message about the app’s code or rules, or unavailable when the platform couldn’t put it live; a failed rollback also has rollback: true. |
grant.put, grant.remove |
An app is shared, or unshared. |
token.create, token.revoke |
A bypass token is made or revoked. |
secret.put, secret.remove, app.env |
A secret or an environment variable changes. The log names it, never its value. |
app.public_paths |
The public paths change. The entry lists every path open afterwards. |
webhook.set, webhook.update, webhook.rotate, webhook.retire, webhook.remove |
A public path’s verifier is added or replaced, has its tolerance changed, gets a new secret, stops accepting the old one, or is removed. The log names the path and the provider, never the secret. |
plan.limit |
Something was refused because of the plan. The entry names the limit. |
mail.sent, mail.skipped |
An invitation went out, or a daily budget stopped it. The entry names the recipient, never the content. |
billing.checkout, billing.plan, billing.plan_undo |
An owner starts a subscription, changes plan, or undoes a change. |
workspace.export, app.database_export |
Someone exported the workspace or an app’s database. |
How long it’s kept
Section titled “How long it’s kept”| Plan | Kept for |
|---|---|
| Free | 3 months |
| Team | 3 months |
| Business | 12 months |
Older entries are deleted. After a move to a plan that keeps less, entries past the new plan’s retention are deleted 30 days after the change. The workspace’s owners get an email about it.
To keep a copy, export the workspace: an owner can do it from the workspace’s Settings. The export is one JSON document with the audit log the plan keeps, along with the members, sharing grants, the names of tokens, secrets and environment variables, and each public path’s verifier without its secret. Deleting an app leaves its entries in the log until they age out.