Stop System.Text.Json from accepting the same JSON property twice

Nothing stops a caller from putting the same property in a JSON body twice. When one does, System.Text.Json keeps the last copy, passes that value to your endpoint, and answers 200 with no warning. That's harmless until something else reads the same body first and keeps a different copy, and on a public API something often does: a gateway rule, a firewall filter, a spend check. My advice is to turn the rejection on wherever a second reader sees the body. In .NET 10 that's one property, AllowDuplicateProperties, and an ambiguous request gets refused instead of read two ways.
What the defaults do
Here is the body. You won't get one like this out of a serializer, which is the point. It comes from a caller who typed it on purpose.
{"amount": 10, "to": "alice", "amount": 1000}
Deserialize that the way minimal APIs and controllers do, with the web defaults, into a record with an Amount property, and you get 1000. JsonDocument.Parse keeps the last copy too. The JSON spec never said what a parser should do with a repeated name, so keeping the last one is a legal answer. It just isn't the only one. A reader somebody wrote by hand could as easily keep the first.
One request, two amounts
So you have two pieces of software reading one body and disagreeing about what it says. Say the check in front of your API enforces a spend limit of 100. It reads amount, sees 10, and lets the request through. Your endpoint deserializes the same bytes and gets 1000. The limit was applied to a number your code never used.
My demo has a small stand-in for that first reader, and it keeps the first copy. I haven't named a real gateway or firewall here. Which copy a given product keeps is something to test on your own setup, and the fix is the same either way.
Turning the rejection on
.NET 10 added JsonSerializerOptions.AllowDuplicateProperties, and it starts out true. For a minimal API, one call changes it for every endpoint:
builder.Services.ConfigureHttpJsonOptions(options =>
{
options.SerializerOptions.AllowDuplicateProperties = false;
});
The same body now gets a 400 while ASP.NET Core is still binding it, with no try/catch anywhere in your own code. The message comes from the exception the serializer throws:
Duplicate property 'amount' encountered during deserialization of type 'Payment'.
Outside the Development environment, nobody sees that message. The 400 arrives with an empty body, and ASP.NET Core logs the reason at debug level, which the default log level hides. AddProblemDetails() on its own doesn't fill that body in, so if callers need the reason, you write the error response yourself.
One thing to know about how far the check reaches. It works by noticing that a member was assigned twice, so it follows your type. A repeated property your record actually binds is caught at any depth, nested objects included. A repeated property your type ignores goes straight through, because nothing got assigned twice. If the reader in front of your API cares about a field your type doesn't have, this setting won't line the two of them up.
The Strict preset, and when I'd use it
.NET 10 also added JsonSerializerOptions.Strict, a ready-made set of options that rejects duplicates among other things. I'd keep it away from an API that already has callers. Measured against the Web defaults an ASP.NET Core app starts from, Strict changes seven settings, and two of those break even an ordinary request that works today. It turns case-insensitive name matching off, and it refuses any property that doesn't map to a member. So an ordinary camelCase body with no duplicate in it stops working:
The JSON property 'amount' could not be mapped to any .NET member contained in type 'Payment'.
That 400 has nothing to do with duplicates. On a new API with no callers yet I'd start from Strict and keep it. On one that's in production, change the single setting, ship that, and read the preset's other six at your own pace.
The change earns its keep unevenly. An internal service with one reader and nothing in front of it gains very little: a duplicate property is just a strange request there, and last-wins hurts nobody. Where I'd set it is anything public, and anything sitting behind a gateway, a firewall rule, a signature check, or any code that reads the body before your endpoint does. It costs one line, and a 400 for callers who were sending something ambiguous to begin with.
The demo is at github.com/gdhami-net/stj-duplicate-properties-demo: the same body through four option sets, with the values and the 400s above quoted from its own endpoints.