← Blog

September 16, 2026 · Steve Johnson

A Privilege Escalation Path Hiding in a Profile Edit Endpoint

At Letterstory, staff accounts get elevated access based on their email domain — a reasonable, common pattern for internal tooling: if your account's email belongs to the company, you're staff, and staff can see and do things regular org members can't. The check itself was correct. What wasn't checked was whether a user could change their own email to one that would pass it.

The gap between "verified once" and "verified every time"

The staff check read the user's current email off their profile and compared its domain against an allowlist. That's fine at signup, when the email is verified through the auth provider. It stops being fine the moment a profile-edit endpoint exists that lets a user change their own email field directly, without re-running that verification — which one did, because profile editing is exactly the kind of endpoint that gets built early, for a legitimate reason, before anyone's thinking about it as an attack surface for a completely unrelated privilege check elsewhere in the system.

Nothing about the profile-edit code was doing anything wrong on its own terms. It updated an email field, which is what it was built to do. The vulnerability existed entirely in the gap between two pieces of code that had no reason to know about each other: one that trusted the email field as a signal of identity-provider-verified domain membership, and one that let a user write to that same field directly.

Why this is easy to miss and easy to fix once found

This is the shape of privilege-escalation bug that doesn't show up by reading either function in isolation. The staff-check function looks correct — it's checking exactly what it says it's checking. The profile-edit function looks correct — it's letting a user edit their own profile, which is the entire point of a profile-edit endpoint. The bug only exists in the relationship between them, and finding it requires asking a specific, adversarial question that neither function's author was necessarily thinking about while writing it: "what happens if a user deliberately sets this field to a value they know will pass a check somewhere else?"

The fix, once found, was small: harden the staff-role check so it can't be satisfied by a self-edited field — either by re-deriving domain trust from the identity-provider-verified value rather than a user-writable one, or by locking the email field against direct edits and routing any real change back through re-verification. The code change is minutes of work. Finding the need for it is the actual cost, and it only gets paid by someone deliberately looking for exactly this shape of gap.

The review habit this reinforced

I now specifically look for any authorization check that derives its decision from a field a user can write to directly, anywhere else in the system — not just on the same page, not just in the same request flow. A field doesn't have to be labeled "permissions" to function as one. If a value gates access anywhere, and a completely unrelated endpoint lets a user set that value, that's a privilege-escalation path, whether or not either piece of code was written with the other in mind. The two functions that create it are usually both individually correct. That's exactly why this category of bug survives normal review — nothing about either half looks wrong until you're specifically looking for the combination.