Duplicate JSON keys: .NET gives three different answers to one string

RFC 8259 §4 says object names SHOULD be unique and states that behaviour is “unpredictable” when they are not. Measured on four runtimes, Node, Python and Go all return the last value — and .NET’s three JSON APIs return the last value, both values, and a thrown exception, from the same input.

Every result below was executed on Node 23.7.0, Python 3.13.5, Go 1.22.0 and .NET 10.0.400. The input in every case is the same nine words:

{"role":"user","role":"admin"}

What the four runtimes do

Node 23.7.0    JSON.parse                 {"role":"admin"}        1 key
Python 3.13.5  json.loads                 {'role': 'admin'}       1 key
Go 1.22.0      json.Unmarshal -> map      map[role:admin]         1 key
Go 1.22.0      json.Unmarshal -> struct   {Role:admin}

.NET 10.0.400  JsonDocument               role=user role=admin    2 properties
.NET 10.0.400  Dictionary<string,string>  role = admin            1 key
.NET 10.0.400  JsonNode.Parse             ArgumentException

Three of the four runtimes agree, and that agreement is a convention rather than a rule — nothing in the specification requires it, and none of the three tells you a name was repeated. Go’s DisallowUnknownFields does not help, because a duplicate name is not an unknown field.

Node has one wrinkle worth knowing. Key order follows first appearance while the value comes from the last occurrence, so a document with a, b, a parses to {"a":3,"b":2} — a stays in front and carries the later value. If you are diffing serialised output, that can make a changed document look reordered rather than altered.

The .NET case, in detail

The three results are not a bug. They come from three APIs that resolve the conflict at different layers of the stack.

JsonDocument.Parse(s)
  EnumerateObject()        -> user, admin        both properties survive
  GetProperty("role")      -> admin              lookup takes the last
  TryGetProperty("role")   -> admin

JsonSerializer.Deserialize<Dictionary<string,string>>(s)
                           -> admin              1 key

JsonNode.Parse(s)
  -> ArgumentException: An item with the same key has already been added.
     Key: role

JsonDocument is the interesting one, because the disagreement is inside a single object. Enumerating gives you user first and admin second; asking for the property by name gives you admin. Code that iterates to validate and then looks up to act is reading two different values from one parse, and nothing in the type system marks the difference.

What the specification says, and why it said it

RFC 8259 §4 is unusually candid here. After the SHOULD, it continues: an object whose names are all unique is interoperable in the sense that all implementations will agree on the name-value mappings, and when the names are not unique, the behavior of software that receives such an object is unpredictable. It then predicts exactly the spread we measured — many implementations report the last pair, and others report an error or fail to parse.

So this is not a gap someone forgot to close. The working group looked at a field that had already diverged and declined to break existing parsers by mandating one behaviour. The practical consequence is that a duplicate name is a document-level defect, not a parser-level one, and it has to be handled where documents enter your system.

Where it turns into an authorisation bug

The example above is deliberately on the nose. Put a validating gateway in front of a service and the two can read one payload differently: a gateway iterating JsonDocument properties sees user and approves; the service deserializing into a dictionary acts on admin. Nothing was tampered with in transit, no parser misbehaved, and both components are conforming.

The same shape applies to any field a decision depends on — a tenant identifier, a currency amount, a scope list. It needs two parsers in the path, which sounds exotic until you count them: a gateway, a schema validator, a logging sidecar and an application are four, and in a polyglot system they are rarely the same library.

Detecting it, rather than resolving it

The thing that makes this hard to catch is that the evidence is destroyed by parsing. Once you hold an object, it has one key, and no check written against that object can recover how many the source had. You have to inspect the text.

Python exposes the hook directly, and it is the clearest demonstration of the point:

json.loads(s)                                -> {'role': 'admin'}
json.loads(s, object_pairs_hook=lambda p: p) -> [('role', 'user'), ('role', 'admin')]

Same string, same standard library, one call away from seeing the problem. Elsewhere the practical route is a token-level pass that counts names per object before anything is materialised. Then reject, rather than resolve: picking a winner embeds your parser’s convention into your API contract, and the next component in the chain has a different one.

The formatter on this site flags repeated names rather than silently collapsing them, because it scans the source text instead of reading back a parsed object. For how a different silent transformation bites in the same place, see JSON big numbers, where a value is rounded rather than dropped.

Frequently asked questions

Is a JSON document with duplicate keys valid?

Yes, strictly speaking. RFC 8259 section 4 says the names within an object SHOULD be unique — SHOULD, not MUST — so a parser that accepts duplicates is conforming. The specification then states plainly that when names are not unique, the behaviour of software receiving the object is unpredictable, and it describes the field: many implementations report only the last pair, others report an error, and others fail to parse. That sentence is the whole problem in miniature. The document is valid, every parser handling it is conforming, and the value you read out depends on which library you happened to call. Validity is simply the wrong question to ask about this input; agreement is the question, and nothing guarantees it.

What do Node, Python and Go actually do?

All three take the last value, measured directly rather than read from documentation. Parsing the string with role appearing twice, Node's JSON.parse returns one key whose value is the second one, Python's json.loads does the same, and Go's encoding/json behaves identically whether the target is a map or a struct. Go's DisallowUnknownFields does not change it, because the duplicate name is not an unknown field. One detail is worth knowing about Node: key order follows first appearance while the value comes from the last, so parsing a, b, a yields a first with the second a's value. Nothing in any of the three emits a warning, a flag or a diagnostic.

Why does .NET disagree with itself?

Because System.Text.Json exposes three entry points that resolve the conflict at different layers. JsonDocument keeps the raw structure, so enumerating the object yields two separate properties — the first value and then the second — while a direct GetProperty lookup returns the last. Deserializing into a Dictionary collapses to one key holding the last value, matching the other runtimes. JsonNode.Parse builds a keyed node and throws an ArgumentException saying an item with the same key has already been added. We measured all three on .NET 10.0.400 against one string. None of them is wrong; they are three reasonable answers to a question the specification declined to settle.

How is this a security problem?

Because two systems reading the same bytes can reach different conclusions, and the gap between them is where authorisation decisions fall through. Consider a gateway that validates a request body and a service that acts on it. If the gateway iterates properties with JsonDocument it sees the first role and may approve against that; if the service deserializes into a dictionary it acts on the last. One payload, one network hop, two different roles. The same shape applies to any field a policy depends on — amount, tenant, scope. The fix is not to pick the right parser but to reject the input: a document whose names repeat is ambiguous by the specification's own account, so it should never reach the layer that decides anything.

How do I detect duplicates before they matter?

You have to look at the text, because every structure built from it has already discarded the evidence. The parsed object holds one key no matter how many the source had, so a check written against the result cannot see the problem. Python gives you the hook directly: json.loads with object_pairs_hook set to the identity function returns a list of pairs, and we confirmed it surfaces both entries where the ordinary call shows one. In other languages the practical route is a streaming or token-level reader that counts names per object, which is what the scanner in this site's formatter does. Reject rather than resolve, and do it at the edge, before any component has had a chance to choose a value for you.

References

Paste a document into the JSON formatter to see whether any of its objects repeat a name. More browser-only tools at withuse.io/tools.