One line of code can undo your entire access control model. Spread the request body straight into a database update and you have handed the client a pen to rewrite your schema. That is mass assignment. It is not an exotic bug or a fringe case. It is a design failure that appears whenever an API treats a payload as its own update policy.
await db.users.update(req.params.id, { ...req.body });
It looks clean. It saves typing. But the client controls the keys. An attacker can add "role": "admin", "accountId": "someone_else", or "credit": 99999" to an otherwise ordinary profile update. Your validation layer might check whether those values are strings or numbers and say they look fine. Validity, however, is not authorization. A user might legitimately own the target record. That does not mean they should have the right to edit every field inside it.
What Mass Assignment Actually Looks Like
The danger hides in convenience. Frameworks and ORMs make it trivial to map JSON keys directly onto database columns. When you do this, you are telling the database to trust the client about what should change, not just how it should change.
A user updating their profile might send valid data for displayName and bio, but slip in role or balance alongside them. If your controller simply forwards the object, the database writes it all. Validation catches malformed values. It rarely catches malicious keys. The business rule that says "this user is allowed to update their profile" becomes a blanket permission over every column in the row.
The fix is not more validation. It is stricter architecture.
The Three Gates
A safe mutation passes through three separate checks before it ever touches storage.
Accepted Fields (Allowlist)
Start by deciding exactly which keys you will even look at. If a field is not on the allowlist, reject the request or drop the key. This flips the default posture: new database columns are non-writable until a developer explicitly exposes them. Schemas grow over time. A teammate adds a stripeCustomerId, a departmentBudget, or an isVerified flag. With an allowlist, those new columns are automatically protected from client writes. Without one, each new column is an accidental API surface.
Valid Values
Once you know which fields are permitted, check whether the values make sense. Is the timezone string actually a recognized timezone? Is the email formatted like an email? Is the number within a sane range? This is hygiene. It stops garbage from entering your system, but it does not stop abuse. A perfectly valid "admin" string is still dangerous in the role field if the wrong person sends it.
Authorized Transitions
This is the gate most teams skip, and it is where the real protection lives. Ask a granular question: does this specific actor have permission to mutate this specific field on this specific record? Not "is the user an admin?" Not "does the user have the write:users scope?" Rather, "is this user allowed to change their own displayName, but never their accountId?" Per-field authorization keeps a broad permission like "Editor" or "User" from becoming a master key to every property in the row.
Building the Patch Function
Tie the three gates into a single pipeline. When a patch request arrives, run it through the stages in order.
First, filter the input against your allowlist. If role is not an allowed field for this endpoint, stop right there. There is no reason to validate or authorize a value you should never have received.
Second, validate the allowed values. Check types, formats, and business rules. A location field must be a string that resolves to a real timezone. An avatar URL must be a valid URI under a certain length.
Third, authorize the action. Verify that the actor owns the target record, or holds the exact permission required for this field. Ownership is a good default for personal data, but some fields still need extra gates. A user might own their profile, yet only a billing admin should touch taxRegion.
Fourth, normalize the data. Trim whitespace, collapse repeated spaces, lowercase emails, or strip control characters. Do this after validation but before storage so you are not comparing dirty strings during authorization checks.
입력값이 어떤 게이트라도 통과하지 못하면, 뮤테이션 전체를 거부하십시오. 안전한 필드만 부분적으로 적용하고 잘못된 필드를 조용히 버리지 마십시오. 혼합된 응답을 주면 클라이언트가 가능한 모든 키를 무작위로 던져보고 무엇이 통하는지 확인하도록 학습시키는 꼴이 됩니다. 명시적으로 실패를 알리십시오.
실제로 중요한 엣지 케이스(Edge Cases)
대량 할당(Mass assignment) 방어는 유닛 테스트가 종종 놓치는 세부 사항에 따라 성패가 갈립니다.
중복된 JSON 키. 공격자는 {"role": "user", "role": "admin"}과 같은 페이로드를 보낼 수 있습니다. 사용하는 HTTP 파서와 프레임워크에 따라, 애플리케이션 코드가 객체를 확인하기 전에 두 번째 값이 첫 번째 값을 덮어쓸 수 있습니다. 이 동작을 파서 수준에서 테스트하십시오. 만약 프레임워크가 마지막 키를 조용히 수용한다면, 허용 목록(allowlist)은 "user"를 보고 있지만 데이터베이스는 "admin"을 받게 될 수도 있습니다.
중첩된 객체, null, 그리고 배열. 페이로드가 평면적(flat)이라고 가정하지 마십시오. 클라이언트는 { "profile": { "role": "admin" } }와 같이 중첩된 객체 안에 제한된 필드를 감싸서 보낼 수 있습니다. 스키마가 재귀적이라면 허용 목록도 재귀적으로 동작해야 합니다. 마찬가지로 null을 어떻게 처리할지 결정하십시오. "이 필드를 무시"하는 것입니까, 아니면 "이 필드를 삭제"하는 것입니까? 또한 배열이 예상되는 경우, 검증기(validator)가 예상치 못한 구조를 거부합니까, 아니면 단일 객체를 배열로 캐스팅하여 통과시킵니까?
유니코드 정규화(Unicode normalization). 두 문자열은 사람 눈에는 동일해 보일 수 있지만 바이트 시퀀스는 다를 수 있습니다. 사용자가 결합된 형태의 é를 보낼 수도 있고, 분해된 형태의 e와 결합용 악센트를 보낼 수도 있습니다. 권한 확인 단계에서의 정규화와 저장 계층에서의 정규화 방식이 다르다면, 데이터 불일치가 발생하거나 더 심각하게는 사용자 이름 충돌이 로직을 통과하여 우회 공격이 발생할 수 있습니다. 정규화는 초기에, 그리고 일관되게 수행하십시오.
경쟁 상태(Race conditions). 권한 결정은 정지된 화면이 아닙니다. 특정 시점에 발생합니다. 두 개의 요청이 동일한 레코드를 읽고, 둘 다 행위자(actor)에게 쓰기 권한이 있음을 확인한 뒤, 둘 다 업데이트를 실행할 수 있습니다. 그 사이에 상태나 행위자의 권한이 변경되었을 수도 있습니다. 데이터베이스 업데이트를 적용할 때는 항상 버전 번호나 상태 머신(state machine) 값에 대한 조건을 포함하십시오. UPDATE users SET ... WHERE id = ? AND version = 5와 같은 방식을 사용하십시오. 읽은 이후에 행이 변경되었다면 쓰기에 실패할 것입니다. 실패 시 재시도하거나 거부하는 방식으로 처리하십시오. 이를 통해 오래된 권한 확인 결과가 데이터를 손상시키는 것을 방지할 수 있습니다.
중요한 것을 모니터링하십시오
보이지 않는 것은 보호할 수 없습니다. 단순히 작업(action)뿐만 아니라 결정(decision)을 중심으로 감사 로그(audit logging)를 구축하십시오.
행위자 ID와 대상 ID를 기록하십시오. 수락된 정확한 필드 이름과 거부된 필드 이름을 기록하십시오. 결정을 내린 정책 버전과 최종 결과를 기록하십시오. 사용자의 프로필 업데이트에서 갑자기 role 필드가 거부되기 시작한다면, 즉시 이를 알아차릴 수 있어야 합니다.
베어러 토큰(bearer tokens)을 절대 로그에 남기지 마십시오. 요청 본문(request body) 전체를 로그에 쏟아붓지 마십시오. 감사 추적(audit trail)은 남용 사례를 조사하는 데 도움이 되어야지, 자격 증명과 개인 정보의 저장소가 되어서는 안 됩니다.
당신에게 필요한 유일한 규칙
요청 본문은 데이터를 제안할 뿐입니다. 스스로 권한을 정의할 수 없습니다. 클라이언트는 무엇이든 요청할 수 있습니다. 영구 저장소에 저장될 수 있는 데이터가 무엇인지 필드 단위, 행 단위로 결정하는 것은 서버의 몫입니다. 이러한 분리 원칙을 염두에 두고 패치(patch)를 구현한다면, 대량 할당 문제는 권한 확인 계층에 도달하기도 전에 해결될 것입니다.
