Appearance
Capabilities in the URL
A team shared a writable room link in chat. The key sat in the query string. It appeared in access logs, analytics, browser history exports, and a third-party site's Referer header after someone clicked away.
The sharing flow was convenient because the URL carried authority. The implementation had treated that authority as ordinary navigation data.
A capability link is a bearer credential. Possession grants a right to a resource. It suits a small poll, game room, family list, or temporary collaboration where an account would cost more than it protects.
Capability map
text
space creation
|
+-> random space id
+-> random read-write key -> stored as hash
+-> random read-only key -> stored as hash
shared URL
https://app.example/room#s=SPACE&k=KEY
|
+-> fragment stays in browser navigation
SDK reads fragment -> authenticates sync request -> server returns roleThe space ID selects a resource. The key authorizes an operation. Neither identifies a human.
Terms
Capability. An unforgeable token that combines designation of a resource with authority over it.
Bearer credential. A secret accepted based on possession, without proof of a separate identity.
Read-write capability. A key that permits reads and mutations.
Read-only capability. A key that permits reads but rejects mutations.
URL fragment. The portion after #. Browsers do not send it in the HTTP request for the page.
Ambient authority. Permission available implicitly, such as a broad account cookie sent to many routes.
Revocation. Removal of a capability's authority before its natural expiry.
Put the invitation in the fragment
A share link can carry the space and key:
js
function makeShareLink(pageUrl, spaceId, key) {
const url = new URL(pageUrl);
url.hash = new URLSearchParams({s: spaceId, k: key}).toString();
return url.href;
}
function readCapability(locationUrl) {
const url = new URL(locationUrl);
const params = new URLSearchParams(url.hash.slice(1));
return {space: params.get("s"), key: params.get("k")};
}The fragment does not reach the static page server and is not included in normal referrer headers. That removes several accidental disclosure paths.
It does not make the key invisible. Browser extensions, page JavaScript, screenshots, copied messages, history synchronization, and malware on the device can read it. Any third-party script on the page shares the capability's trust boundary.
The sync SDK must eventually present the key to the sync service. REST can use an Authorization header:
http
Authorization: Space eyJrandomUrlSafeKeyThe browser WebSocket constructor does not allow arbitrary authorization headers. Common designs use a query parameter during the upgrade, a short-lived ticket obtained over authenticated HTTP, or a subprotocol value. If the long-lived key enters the WebSocket URL, redact query strings from proxy and application logs.
Draw a dependency boundary
Vendor runtime code on capability-bearing pages when practical. This removes a live supplier from each page load.
If a CDN is unavoidable, use exact immutable URLs, a restrictive Content Security Policy, and Subresource Integrity where the loading mode supports it. Keep analytics, tag managers, and unrelated widgets off the page.
Pinning helps repeatable builds, but does not stop a compromised package or CDN. SRI checks fetched bytes. CSP limits script sources. Neither catches code that was already compromised when approved.
Any remaining third-party runtime can read the fragment and act with the holder's authority. Its supplier joins the trust boundary. Record that as a security decision.
Store hashes, not raw keys
Generate keys with a cryptographic random source. Short human-friendly codes need strict guessing limits.
Store a one-way hash for each role. On a request, hash the presented key and compare fixed-length values in constant time. Compare both role hashes before returning so response timing does not reveal which slot matched.
Raw keys should exist only on clients that received them. The creator may keep both read-write and read-only keys locally so it can produce either invitation. A visitor who opened a read-only link should not be able to derive the write key.
Keep the application, space ID, and key in the authorization input. A valid key for one space must not authorize another.
Capabilities are authorization, not identity
If Sam forwards a write link to Lee, the service sees the same capability. It cannot prove which person made a change. A locally generated device ID can stamp writes for conflict resolution, but that ID is self-asserted.
This is acceptable for low-risk cooperative spaces. It is not acceptable when the system needs:
- Per-person audit records.
- Organization membership.
- Account recovery.
- Individual bans.
- Billing ownership.
- Legal consent.
- Reliable attribution.
Capabilities and accounts can coexist. An account may create a room, while invitees enter through narrow room capabilities. The server can then distinguish owner administration from guest data access.
Scope rights narrowly
Two roles are a useful minimum:
- Read-write can read current state and submit changes.
- Read-only can read and receive live updates but gets a clear forbidden response on writes.
Do not use a friendly replicated "members" set as the authorization source. A client-side merge cannot safely decide who may revoke whom.
For higher-risk systems, add separate capabilities for administration, inviting, moderation, or exporting. Avoid one master URL that grants every future feature.
Quota checks still apply after authorization. A valid write key should not permit an unbounded request, document flood, or connection storm.
Revocation is the hard part
A copied bearer token cannot be recalled from another person's clipboard. The server can stop accepting it, but every legitimate holder then needs a replacement.
Practical options include:
- Rotate the read-write key and redistribute it.
- Keep read-only access while rotating only writes.
- Expire spaces or invitations.
- Put a server-side capability record behind the random token so one record can be revoked.
- Require an account before granting long-lived access.
Offline writes complicate rotation. A device may queue changes under an old key. On reconnect it should receive a stable unauthorized or forbidden result and keep the local work available for export or owner-assisted recovery. Silent deletion is hostile.
Rejected assumptions
The fragment is encrypted. It is merely omitted from the page request.
A random device ID identifies a person. Clearing storage creates a new one, and scripts can choose any value.
CORS protects the API from attackers. CORS limits browser reads from disallowed origins. It does not stop direct HTTP clients holding a key.
Read-only can be enforced only in the UI. The server must reject writes for that role on every transport.
Anyone with the link is harmless. A link can be posted publicly or indexed in a chat export.
Failure modes
- A share button uses
location.hrefafter app routing has overwritten the fragment. - Analytics records the raw fragment.
- Error reports include complete request URLs.
- A WebSocket proxy logs the
keyquery parameter. - The creator loses the read-only key and cannot mint a safe viewer link.
- A read-only client queues writes before learning its role.
- Key rotation strands unsynced local changes.
- A preview or automated test creates durable spaces and consumes quotas.
- Third-party scripts run with access to every capability in the page.
Field checklist
Capability links answer "what may this holder do?" They do not answer "who is this person or device?" Chapter 18, Auth for humans and devices separates those concerns. Merge semantics remain independent, as shown in Chapter 16.