Your API Is Leaking: Over-Serialization and the Contracts You Didn't Mean to Sign
render json: @user feels harmless until a column you added for internal bookkeeping shows up in a partner's integration. On accidental contracts and serializing on purpose.
A partner integration once broke because we removed a field we never knew we were sending. Their developer had built against internal_score — a column our data team used for experiments, never documented anywhere, never intended to leave the building. It went out the door because an endpoint somewhere did render json: @account, and to_json on an ActiveRecord model serializes every column it has. When we dropped the experiment and the column, their nightly sync started failing, and suddenly we were in a meeting about "the breaking change to the API."
They weren't wrong, either. That's the uncomfortable truth about APIs: whatever you send becomes the contract, whether you meant it or not. There's an old observation — Hyrum's Law — that with enough users, every observable behavior of your system will be depended on by somebody. Your response payloads are the most observable behavior you have.
How the leak happens
Nobody sets out to expose their schema. It happens through a series of individually reasonable moves:
def show
render json: @user
end
Day one, the users table has id, email, name. Fine. Over the next two years, migrations add stripe_customer_id, admin_notes, failed_login_count, internal_flags, password_reset_token. The controller never changes. Every one of those columns starts flowing out of the API the moment its migration runs — no diff ever shows "added password_reset_token to the public API," because no code changed. The contract grew silently, in migration files.
That's the core problem with default serialization: it couples your public interface to your storage layout, two things that change for completely different reasons and at completely different speeds.
The leak has three distinct costs:
- Security. Sometimes it's embarrassing (
admin_notes), sometimes it's a genuine incident (reset tokens, internal IDs that enable enumeration, feature-flag hints that reveal unannounced launches). - Flexibility. Every leaked field is a field you can no longer rename, retype, or drop without potentially breaking someone. Your database schema is now frozen by strangers.
- Comprehension. Consumers can't tell which fields are load-bearing. So they build against all of them, and Hyrum's Law does the rest.
Serialize on purpose
The fix isn't a specific gem — it's a stance: the response shape is code you write deliberately, with an allowlist, in one findable place. Any of the usual tools work (a serializer class, a plain Ruby presenter, Jbuilder, Blueprinter). What matters is that the fields are enumerated:
class UserSerializer
def initialize(user) = @user = user
def as_json(*)
{
id: @user.public_id,
name: @user.name,
email: @user.email,
created_at: @user.created_at.iso8601
}
end
end
Now adding a column to users changes nothing about the API until someone deliberately adds a line here — a line that shows up in a diff, gets reviewed, and can be questioned. The migration-to-API pipeline is severed. That's the whole game.
A few deliberate choices worth making while you're in there:
Expose public IDs, not primary keys. Sequential integers leak volume ("we're customer 1043 of what, exactly?") and invite enumeration. A UUID or prefixed public ID (usr_9f2k…) costs one column and removes a whole category of awkwardness.
Shape for the consumer, not the schema. The client needs display_name and avatar_url; it does not need your first_name/last_name/middle_name normalization saga, or the has_many :through chain you used to compute things. Flatten. Rename. Compute. The response is a view, not a mirror.
Nail down types and formats. Timestamps as ISO 8601 strings, money as integer cents with a currency field, enums as strings not integers. Every place your serializer just passes through "whatever the column happens to be," you've delegated a contract decision to your database.
Stability is a feature you build
Once the shape is deliberate, keeping it stable is the next discipline.
Additive changes are usually safe; everything else is a breaking change. Adding a field rarely breaks a reasonable consumer. Removing one, renaming one, changing a type, making a nullable field disappear when empty — those break integrations, usually at night. Treat them with migration-grade caution: announce, dual-publish (new field alongside old), deprecate, then remove on a schedule.
Write the contract down and test it. A schema test — even a simple one asserting the exact key set of a response — turns "we accidentally changed the API" into a red build instead of a partner escalation:
it "does not silently grow the user payload" do
get api_v1_user_path(user), headers: auth_headers
expect(response.parsed_body.keys)
.to match_array(%w[id name email created_at])
end
I love this test precisely because it fails when someone adds a field carelessly. Making the developer update the test is the point — it converts an accident into a decision.
Version when you must, but reach for it last. /v2/ is a heavy tool: you'll maintain both versions far longer than planned. Most evolutions fit inside additive changes plus deprecation windows. Version for genuine redesigns, not renames.
The AI wrinkle
I'll flag a pattern I keep seeing in review lately: generated endpoint code leans heavily on render json: with the raw model, or as_json with a hand-wavy except: [:password_digest]. The blocklist version is the trap — an assistant (or a hurried human, we've all been there) excludes the two sensitive fields it can see today, and every future migration re-opens the leak. Blocklists rot; allowlists don't. When a generated diff serializes a model, my review question is always the same: "show me the explicit field list, and tell me which consumer asked for each field." Half the time the honest answer trims the payload by two-thirds.
What I'd do instead
My working rules for response payloads, accumulated the hard way:
- Never serialize a model by default. Every response goes through an explicit, allowlisted shape — serializer, presenter, whatever your codebase already uses.
- Ask "who needs this field?" for every field. No named consumer, no field. You can always add it later; you can almost never remove it.
- Public IDs out, primary keys stay home. Same for anything with the words
internal,token,secret, ornotesin it. - Lock the shape with a test that fails on any key change, additive or not. Additions should be deliberate too.
- Removing or renaming a field is a project, not a commit. Dual-publish, deprecate, communicate, then remove.
- Review generated serialization code for blocklists and convert them to allowlists on sight.
The through-line is simple: an API response is a promise, and the only fields you can safely promise are the ones you chose on purpose. Everything else is a leak — and leaks, in my experience, are only ever discovered by the people you least wanted to find them.