In April 2017, Slack pushed a code change that, for any user creating a shared invitation link, included the user’s bcrypt-hashed password in a server-side payload that was transmitted to other workspace members. Workspace members couldn’t see it in the UI, but it was in the response body. The bug stayed in production until July 2022 — five years and three months — and was discovered by an external researcher.
Slack disclosed it (their original disclosure is here), force-reset all affected accounts, and moved on. As far as I can tell from the public record, no impersonation incidents resulted directly from the leak — bcrypt hashes are reasonably hard to reverse, and the affected user set didn’t include the kind of accounts that an attacker would prioritize. The damage was reputational, not material.
But the incident is one of those rare cases where the type of bug is more illuminating than the severity. It tells us something about how a specific architectural pattern fails, and it gives us a concrete rule to design around.
The bug, in plain terms
Slack’s user-creation pipeline produced a User model that included a bcrypted_password_hash field. This field exists on the database row, and it exists on the in-memory representation of the user that the application code passes around. Most code paths don’t need it. A few code paths — login, password change — do.
The bug was that a different code path, one for “produce a user-payload to embed in a workspace-shared invitation link,” also got the full User object — and serialized it whole. The payload was meant to include the user’s display name and avatar, the kind of fields you put on an invitation card. But because the serializer was deny-list-shaped instead of allow-list-shaped — meaning “remove these specific sensitive fields” instead of “include these specific safe fields” — when the bcrypt-hash field was added to the model, no one updated the deny-list, and the field went out the door.
The architectural pattern that produced this bug is “serialize the whole object, except the things we remember to exclude.” The pattern is dangerous because the failure mode is additive: every time a sensitive field is added to a model, every serializer that uses the deny-list pattern needs to be updated, manually, by someone who knows. Forgetting any one is a leak. There are no compiler errors. There are no test failures. The bug ships silently and stays in production for five years.
The architectural rule
The principle we adopted from this is straightforward: only ever ship to a client the minimum bytes that client needs, and enforce it by always using allow-list serialization.
Concretely, in our application:
- We never
return $user->toArray()from a controller. Ever. The pattern is structurally banned. - Every endpoint returns an explicit “Resource” —
UserResource,MessageResource,ChannelResource— that is a hand-written class enumerating exactly which fields appear in the JSON response, in which shape, with which transformations. - Adding a field to a model does not change what any endpoint returns. The Resource has to be updated separately, deliberately, in a separate commit (we recommend), with the reviewer’s explicit attention to “is this field intended to be public on this surface?”
- A code-review checkpoint asks: “if this field were leaked to a customer’s coworker, is that fine?” If the answer isn’t an obvious yes, the field doesn’t go in the Resource.
- Internal tooling that reads from the database directly is treated separately — those code paths can use the full object, but they live in clearly-marked admin namespaces and they don’t ship to customer-facing surfaces.
This is verbose. The Resource for a user has roughly twenty fields, and each one is hand-written with its allow/deny decision attached. It is also safe by construction: a future engineer adding mfa_secret to the user model cannot leak it through UserResource unless they also explicitly add it there.
Why it matters more than it seems
The Slack bug looks small in retrospect. A bcrypt hash, transmitted to people who couldn’t read it in any UI, sat in payloads that probably weren’t logged anywhere recoverable. The blast radius was modest.
But scale up the same architectural pattern. The same deny-list-shaped serialization is what produced:
- The 2018 Cloudflare bug where customer DNS data was leaking through a router’s logging buffer.
- The 2019 Capital One breach where misconfigured server-side request forgery let an attacker walk the metadata service.
- The 2022 Optus breach where authentication endpoints returned far more user data than the calling client needed.
- A long, long list of “we shipped a field that shouldn’t have shipped” incidents.
The pattern is a class of bug, not an individual mistake. The defense is structural: don’t trust serialization-by-omission. Always serialize-by-inclusion.
We treat this the same way we treat Postgres RLS for tenant isolation — a structural defense at the architecture layer, not a code review checklist that depends on someone remembering. The two together (RLS at the database, allow-list resources at the API boundary) cover the two most common shapes of “data leaked across a boundary it shouldn’t have crossed.”
The lesson generalized
Whenever you have a “ship the object minus some fields” pattern in your codebase, you have an additive vulnerability latent in the architecture. New fields will be added. Old fields will be repurposed. Permission models will be extended. The deny list will go stale. Someday someone will ship a field they didn’t mean to.
The fix is not “add more tests” or “do better in code review.” Both help, neither solves. The fix is structural: change the serialization pattern so that the default behavior is to leak nothing, and the deliberate act is to add a field. Make the safe path the default path.
That is what S2 in our principles means in practice. It is one of the boring, repetitive architectural decisions that compounds over the lifetime of a company. The compounding return is that incidents like Slack’s don’t happen — not because we’re smarter, but because the pattern doesn’t allow it.
— The founder, Stockholm, 2026-05-03