•
6 min read
designing authorization without a security team
security api-design backend

The shared workspace I led turned home search into a social surface: multiple members per conversation, shared content, invites, attachments, later real-time chat. Consumer-facing and multi-tenant, with no dedicated security review gate in the delivery path. The security posture was going to be whatever the engineers building it decided it would be.

These are the decisions, in the form I can defend them. Two of them reversed an earlier call, which is the part worth reading closely.

edit and delete are different verbs

The first rule: author-only edits. Editing someone else’s message changes what they said, so only the author can do it.

Delete was initially any-member. The reasoning, recorded at the time, was that removing content from a space you belong to is moderation, and moderation is a weaker action than rewriting. Different verbs, different rules.

That reversed. Three facts drove it. The delete was a hard delete with no tombstone, so it was unrecoverable. Reads were member-scoped, so any member could enumerate every other member’s message IDs and then destroy them. The client was never the gate either, because the federation proxy passes arbitrary operations through with the caller’s cookie. An irreversible destructive action on another member’s content needs a stronger gate than co-membership. Delete is author-only now, matching edit. A moderation exception for the conversation owner was deferred rather than built, because no product requirement asked for it and adding it later is additive.

The original reasoning was not wrong about the verbs. It was wrong about what “delete” meant in this system, where it was permanent and the IDs were enumerable.

uniform not-found

Whether a resource belongs to another tenant, belongs to another author, or does not exist at all, the API answers identically. The delete above returns the same not-found for all three cases, because the scoped write’s count === 0 cannot tell them apart and should not try.

This is the cheapest defense against cross-tenant enumeration there is. The moment a 403 and a 404 are distinguishable, an attacker can walk an ID space and learn which IDs exist in tenants they cannot see. Making them the same response costs nothing and closes the whole class.

It has a corollary for later features. When a time window was added after which a message can no longer be edited, the window check runs only on a row the caller can already see. A row the guard cannot see falls through to the same uniform not-found, so the window cannot be used to probe which case it was.

bounded arrays

Every list a client sends has a size cap. Attachments per message, items per share, recipients per invite. A crafted request cannot become a memory or query-amplification problem, and the cap is enforced at the write path.

the content-type that let a file render itself

Object storage serves an uploaded file with the content type pinned at upload. The read path decided whether to serve a file inline or force a download from a mimeType column on the attachment row. That column was client-supplied at persist time.

So: mint an upload URL as image/svg+xml, which the allow-list permitted. Upload the SVG. Persist the attachment row as image/png. The read path sees PNG, skips the force-download branch, serves the SVG inline from a signed URL, and the SVG executes script on the storage origin. The guard existed and was documented. It read attacker-controlled data.

The fix removed SVG from the allow-list entirely rather than only tightening the binding. Nothing renders SVG safely from a signed URL, zero SVG objects had ever been uploaded, and removing the format closes the whole bypass class instead of one path into it. The binding was tightened as well, so both halves are closed, and the read-side guard stays for any row written before the change. Keeping SVG and driving the disposition from the object’s own metadata was rejected as an extra round trip on every read to preserve a format no one used.

three client-supplied fields, validated at the write

The same audit found three attachment fields persisted without being checked against the conversation or tenant doing the write. The storage key was minted server-side with the conversation’s prefix, but the persist path accepted any string, so a member could replay another conversation’s key and have the read resolver sign a download for it. The MIME type was allow-list-validated at mint and free-form at persist. The target ID of a shared item was accepted with no existence or ownership check, while the read resolvers deliberately skipped per-item ownership because membership was gated at the parent query.

All three are validated at the write now, inside the existing transaction. The key must carry the prefix minted for the message’s own conversation. The MIME type is constrained to the same allow-list the mint uses, so the two cannot disagree. Item IDs are confirmed to belong to the caller’s tenant, per kind. The write-side check is what makes the read side’s documented “do not re-check ownership” stance actually safe. Scoping the shared data loaders by tenant instead was rejected as the primary fix because those loaders were shared with older paths and changing their signature had a wider blast radius than guarding the one write that accepted free-form IDs.

a presigned upload can constrain size

A comment repeated in several places asserted that a presigned PUT cannot enforce a content-length limit, so upload size was checked after the fact. The comment was correct only about ranges. Signing content-length pins the upload to an exact byte count, and the storage service rejects any other size with a signature mismatch before storing a byte. Verified against a real bucket. Upload size is now enforced rather than checked.

The same change wrote the uploader’s identity in three places, because they fail differently: the server-minted key, the object’s own metadata, and a column on the attachment row. The metadata is the only one covering an upload that is never attached. The key survives losing metadata. The column answers “what did this member upload” without a storage round trip.

what the decisions have in common

None of them needed a tool. They needed someone to ask, for each write, what the client controls and what the server assumes. The two reversals came from the same question asked again after the system had changed: a delete that became permanent, and a guard that turned out to read the wrong column. Security as design decisions is cheap at design time and expensive as a pentest finding, and the difference is only whether anyone asked.