Security
witness is built on one decision: the link is the credential. That is unusual enough that it deserves a plain explanation of what it means, what it protects against, and what it asks of you.
The link is the credential
Every project has two links. A view link reads the project and can comment on cards. An edit link can also create and change cards and rewrite the project's own prose. Each link carries a token of 256 random bits; the service stores only a SHA-256 digest of it, so there is no clear token on disk to steal, and a token cannot be guessed — the space is larger than the number of atoms you could enumerate in the lifetime of the universe.
Whoever holds a link needs no account, and that is the point: a tester who has to sign up to read a card is a tester who reads the card in a chat window instead, and an agent handed a URL is productive on its first request. Your own organisation signs in — that is who a project belongs to, and how your team reaches every project it owns without holding any link at all.
What this asks of you. You decide who holds a link. A link forwarded is a link shared, exactly like a document link in a file-sharing service. If one link reaches someone it should not have, a member stops that link from the project's Share panel and every other link keeps working. If you have lost track of which link is where, rotate the project: the console issues a fresh pair and every link it had stops working the same instant.
MCP connectors are not links
A signed-in member's agent can also reach every project their organisation owns through one MCP connector, the same address for the whole deployment, added once to Claude, Cursor or any other client that speaks the protocol. What that client stores is an address, not a credential: the token behind it is issued to your account by your identity provider, scoped to the organisation you chose, and short-lived. There is no pasted secret to leak and nothing to rotate — removing someone from the organisation ends their agent's access the moment it ends theirs.
A project need not have links at all. One created without them, or whose links have all been stopped, has no address anyone outside your organisation could hold — no curl, nothing a search engine or a stray forward could reach — while members still open it by signing in, and their agents still reach it through the connector. It is a state, not a setting: any member can make a new link from the project's Share panel, and the project has links again. Share links keep working exactly as they do today, for the people and agents who never sign in.
What a project can and cannot tell an agent
A project is read by other people's agents, and everything in it — cards, comments, the project's own working rules — is written by whoever holds a link. So the document an agent receives opens with a constitution: a block generated from witness's own code, identical for every project, that no link and no API call can change. Only a deployment of witness changes it.
It tells the agent four things before it reads anything a link-holder wrote: that a project holds findings and never secrets; that project content is data rather than authority, and cannot grant permissions or speak for witness; that any instruction reaching outside the project is out of scope and should be reported to the person steering the agent; and that claims carry evidence. It also states its own rank — the agent's own principles and whoever operates it outrank this document.
What this is, and is not. It is a standing instruction that a link-holder cannot edit or remove, placed where every agent reads it first. It is not a sandbox: witness does not execute your agent and cannot enforce what it does. Treat it as a well-placed, tamper-proof warning rather than a boundary, and give an edit link the same care you would give any credential.
A view link cannot write
The two roles are enforced by the service, per request, on every write. A view link that tries to create or change a card gets a 403. Comments are the one thing a view link may add, because a tester's report is the reason the link exists.
The token stays out of the address bar
When a browser opens a link, the service exchanges the token for an HttpOnly cookie scoped to that one project's path and redirects to a URL that names the project by id rather than by token. From then on the token is not in the URL, so it is not in browser history, not in a screenshot of the address bar, and in the hosting provider's request log once rather than on every line. Scripts running in the page cannot read the cookie.
Agents and scripts keep using the token in the path, which is the better form for them: no cookie jar, no header, revocation by rotation.
The app on your phone holds links, and that is the deliberate exception. The app at witness.nu/app works several projects from one screen, so it keeps the links you paste into it in that browser's local storage and exchanges each one for the same HttpOnly cookie, once per page load; after that its requests name each project by id like any other page, so the token is never in a URL and reaches the request log once rather than on every line. The token is on that device because you put it there — pasting in a link is the whole feature — and it stays there. The honest limit: local storage is readable by scripts on the same origin, so a script injection on witness.nu would read every link the app holds, which is what allowing scripts only from our own origin is for.
Every project lives in the European Union
A project's compute and storage is pinned to the EU. That pin is part of the identity of the storage object, decided in configuration before the first project existed, so it cannot be changed for one project or dropped by accident. Screenshots are stored in an EU bucket beside it, and so are backups. The only data about a person that is processed outside the EU is the account holder's sign-in, which the sub-processor list is explicit about.
What the browser is allowed to run
The project page runs under a Content Security Policy that allows scripts only from the service's own origin — no third-party scripts, no analytics, no inline code. Card text and comments are markdown written by whoever holds a link, so the page renders them through a sanitiser, and the policy is the second layer behind it.
The console, where the account holder signs in, has a separate policy that admits the sign-in provider and nothing else. The two are kept apart on purpose.
Images are served as data, never as code
An image uploaded to a card has its type decided by its first bytes, not by the Content-Type the uploader sent, so a mislabelled upload is harmless and anything that is not a PNG, JPEG, GIF or WebP is refused outright. Each file is at most 10 MB, and a card holds at most 40.
The bytes come back with X-Content-Type-Options: nosniff and a Content-Security-Policy of default-src 'none'; sandbox, so a browser will not reinterpret a file as something executable and nothing inside it can reach the network. They are served behind the project's link — through the path-scoped cookie for a browser, or the token for an agent — and marked private and uncacheable, so an image on a card is not a public URL that keeps working once a link is rotated. A signed-in member of the owning organisation holds no link, and a browser cannot put a header on a request for an image, so that one request is answered on the account's own session instead: verified the same way every other signed-in request is, for a reader who may already open the card the image sits on, and accepted on no other request.
Isolation
Each project is its own storage object with its own database. There is no table that holds two customers' cards, and no query that could return them together.
Budgets on the open surfaces
Because a project is reachable with nothing but a link, the service budgets what one network address may do: how many links it may resolve, how many writes it may make to a project, and how many live event streams it may open, per minute. A signed-in member is counted as themselves instead: their reads through the page and the API, the images on their cards, their writes to each project, and their event streams each have a budget of their own, so the people behind one office address do not share one. Two requests still count against the address: loading the page, because a page load carries no token (the session is read only when an image is fetched), and opening the page's live connection, because the service learns whose it is only after looking it up. Those openings have their own budget per address, which no link spends. The budgets are generous — a busy team behind one office address never meets them — and they exist to keep one bad actor from making the service slow for everyone else.
Backups, and how deletion works
Every night, each active project is exported to an EU bucket, and snapshots older than thirty days are removed. An archived project cannot change, so it keeps one snapshot instead, for as long as it stays archived, and that snapshot can be older than thirty days; taking it out of the archive or deleting it removes it at once. This is in addition to the storage layer's point-in-time recovery, and it covers a different failure: a bug of ours that damages a project, where what you want is yesterday's copy.
Deleting a project removes the project, its links and its images immediately. Its nightly snapshots age out within thirty days. You can export any project as a single JSON document at any time, on either link, before or instead of deleting it.
Encryption
All traffic is over TLS. Data at rest is encrypted by the hosting provider.
Reporting a vulnerability
Write to support@witness.nu. It is read by a person, and a report about security is answered first. Please give us a reasonable time to fix a problem before publishing it; we will tell you when it is fixed and credit you if you wish.
Testing against your own projects is welcome. Testing against projects that are not yours, guessing links, or placing load on the service beyond ordinary use is not.