JSON and Safe Logging
These methods wrap System.Text.Json for the jobs that come up every day: turning an object into JSON and back, cleaning up JSON text, checking that a payload parses, writing an object to a log without leaking passwords, and getting a stable fingerprint of an object for an ETag or a cache key. Reach for them when you want one readable call instead of a JsonSerializer call with options at every site.
At a glance
| Method | What it does |
|---|---|
ToJsonString | Serializes any object to JSON, using its runtime type |
FromJson<T> | Deserializes a JSON string to T |
PrettyJson | Re-formats JSON text with two-space indentation |
MinifyJson | Removes all insignificant white space from JSON text |
IsValidJson | Checks whether text is valid JSON (false for null, empty or white space) |
ToSafeJson | Serializes an object with sensitive values (Password, ApiKey, Cvv, ...) replaced by "***" |
ToCanonicalJson | Serializes with property names sorted at every depth and no white space |
ContentHash | Lower-case hex SHA-256 of the canonical JSON: an ETag or change detector |
All the methods are in the System namespace, so you do not need a using directive for them. Every method that serializes an object is marked for trimming and AOT; see Trimming and native AOT at the end of this page.
Serialize and deserialize
ToJsonString
ToJsonString(options) serializes the object with System.Text.Json. It uses the object's runtime type, not the static type of the variable, so an object held in an object or base-class variable still writes all of its properties. Pass JsonSerializerOptions to control naming, indentation and converters; leave it out (or pass null) for the System.Text.Json defaults.
public sealed record Order(int Id, string Customer, decimal Total);
Order order = new Order(1042, "Ahmad", 99.50m);
string json = order.ToJsonString();
// → "{\"Id\":1042,\"Customer\":\"Ahmad\",\"Total\":99.50}"
JsonSerializerOptions web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
string camel = order.ToJsonString(web);
// → "{\"id\":1042,\"customer\":\"Ahmad\",\"total\":99.50}"
object? missing = null;
string nothing = missing.ToJsonString();
// → "null"
- A
nullreceiver returns the JSON literal"null"; it does not throw. - Exceptions from
System.Text.Jsonpass through unchanged: a cycle in the object graph throwsJsonException, and a type the serializer cannot handle throwsNotSupportedException. - With the default options, non-ASCII text is escaped.
new { City = "عمّان" }.ToJsonString()gives{"City":"\u0639\u0645\u0651\u0627\u0646"}. Run the result throughPrettyJsonorMinifyJsonwhen people need to read it.
FromJson
FromJson<T>(options) deserializes a JSON string to T with System.Text.Json. It is the counterpart of ToJsonString, so the two round-trip.
string body = "{\"Id\":1042,\"Customer\":\"Ahmad\",\"Total\":99.50}";
Order? order = body.FromJson<Order>();
// → Order { Id = 1042, Customer = "Ahmad", Total = 99.50 }
List<int>? ids = "[3,1,2]".FromJson<List<int>>();
// → [3, 1, 2]
Order? none = "null".FromJson<Order>();
// → null
The System.Text.Json defaults match property names case-sensitively. JSON from a web API is usually camelCase, so pass options that match it:
string body = "{\"id\":7,\"customer\":\"Sara\",\"total\":12}";
JsonSerializerOptions web = new JsonSerializerOptions(JsonSerializerDefaults.Web);
Order? order = body.FromJson<Order>(web);
// → Order { Id = 7, Customer = "Sara", Total = 12 }
Without matching options, "{\"id\":7,\"customer\":\"Sara\"}".FromJson<Order>() does not fail. It returns an Order whose properties keep their default values (Id = 0, Customer = null). If a payload looks empty after deserializing, check the naming policy first.
- A
nullstring throwsArgumentNullException(parameterjson). - Text that is not valid JSON, or does not fit
T, throwsJsonException. For example,"{\"Id\":\"abc\"}".FromJson<Order>()fails with the path$.Idin the message. - The JSON literal
nullreturnsnullfor a reference type or a nullable value type ("null".FromJson<int?>()returnsnull). For a non-nullable value type it throwsJsonException:"null".FromJson<int>()does not return0. - To check a payload without catching exceptions, call
IsValidJsonfirst.
Format and validate JSON text
These three methods work on JSON text, not on objects. They parse with JsonDocument, so they need no type information and are safe to use in trimmed and AOT apps.
PrettyJson
PrettyJson() re-formats JSON text with two-space indentation, one value per line. Use it for log files, debug output and test failure messages.
string stored = "{\"invoice\":\"INV-2024-001\",\"customer\":\"أحمد\",\"lines\":[{\"sku\":\"A-1\",\"qty\":2}],\"note\":\"<b>VIP</b>\"}";
string readable = stored.PrettyJson();
{
"invoice": "INV-2024-001",
"customer": "أحمد",
"lines": [
{
"sku": "A-1",
"qty": 2
}
],
"note": "\u003Cb\u003EVIP\u003C/b\u003E"
}
- Lines are separated by
Environment.NewLine. - Letters of every language (Arabic, accented Latin, ...) are written as they are, unlike the
System.Text.Jsondefault encoder. - HTML-sensitive characters (
<,>,&,',",+and the backtick) are always escaped as\uXXXX, so the output is safe to put inside an HTML page. - Emoji and other characters outside the Basic Multilingual Plane are written as escaped surrogate pairs.
"{\"e\":\"😀\"}".PrettyJson()writes"e": "\uD83D\uDE00". - A scalar is returned as is, without the surrounding white space:
" 42 ".PrettyJson()returns"42".
MinifyJson
MinifyJson() removes all white space that is not part of a string value. Use it before you store JSON in a database column, put it in a message queue or compare two payloads by text.
string payload = """
{
"invoice" : "INV-2024-001",
"lines" : [ 1, 2 ],
"note": "paid in full"
}
""";
string compact = payload.MinifyJson();
// → "{\"invoice\":\"INV-2024-001\",\"lines\":[1,2],\"note\":\"paid in full\"}"
White space inside strings is kept ("paid in full"). The escaping rules are the same as for PrettyJson: non-Latin letters stay readable and HTML-sensitive characters are escaped. "{\"a\": \"<b>&'\"}".MinifyJson() returns {"a":"\u003Cb\u003E\u0026\u0027"}.
- A
nullstring throwsArgumentNullException(parameterjson). - Text that is not valid JSON throws
JsonException. Single quotes ({'a': 1}) and a cut-off document ({"a":) are both invalid. - Text with an unpaired surrogate (for example a string cut in the middle of an emoji) or an escaped unpaired surrogate such as
"\ud800"also throwsJsonException, because it cannot be written as UTF-8.
IsValidJson
IsValidJson() returns true when System.Text.Json can parse the text as one JSON value: an object, an array, a string, a number, true, false or null. Use it to validate a webhook body, a configuration value or a column before you work with it.
bool order = "{\"id\":1}".IsValidJson(); // → true
bool list = "[1,2,3]".IsValidJson(); // → true
bool number = "42".IsValidJson(); // → true
bool literal = "null".IsValidJson(); // → true
bool singleQuotes = "{'id':1}".IsValidJson(); // → false
bool trailingComma = "{\"id\":1,}".IsValidJson(); // → false
bool twoValues = "1 2".IsValidJson(); // → false
bool plainText = "hello".IsValidJson(); // → false
bool blank = " ".IsValidJson(); // → false
null, an empty string and white space returnfalse; the method never throws.- Parsing is strict: comments, trailing commas and single-quoted strings are not accepted.
- Text with a raw unpaired surrogate returns
false. An escaped one such as"\ud800"is valid JSON, so it returnstrue, even thoughPrettyJsonandMinifyJsonthrow on it. - Surrounding white space is allowed:
" { } "is valid.
Safe logging
ToSafeJson
ToSafeJson(additionalSensitiveNames) serializes the object to JSON and replaces the value of every property whose name looks sensitive with the string "***". It works at any depth: nested objects, arrays and dictionary keys are all checked. Use it whenever you put a request, a command or an entity into a log, a trace or an error report.
LoginRequest login = new LoginRequest
{
UserName = "ahmad",
Password = "hunter2",
RememberMe = true,
ApiKey = "sk_live_123",
};
logger.LogInformation("Login attempt {Request}", login.ToSafeJson());
// → "{\"UserName\":\"ahmad\",\"Password\":\"***\",\"RememberMe\":true,\"ApiKey\":\"***\"}"
Secrets inside nested objects and arrays are masked too:
object request = new
{
Path = "/api/orders",
Headers = new { Authorization = "Bearer eyJ...", Accept = "application/json" },
Payment = new { CardNumber = "4111111111111111", Cvv = "123", Amount = 99.5m },
};
string json = request.ToSafeJson();
// → "{\"Path\":\"/api/orders\",\"Headers\":{\"Authorization\":\"***\",\"Accept\":\"application/json\"},\"Payment\":{\"CardNumber\":\"***\",\"Cvv\":\"***\",\"Amount\":99.5}}"
Which names are masked
Before it compares, ToSafeJson lower-cases the property name and removes _ and -. So api_key, Api-Key and X_API_KEY all become a name that contains apikey.
| Rule | Names | Masked examples |
|---|---|---|
| Name contains one of these | password, passwd, secret, token, apikey, privatekey, authorization, cookie, creditcard, cardnumber, connectionstring | UserPassword, clientSecret, access_token, RefreshToken, X-Api-Key, private-key, SetCookie, credit_card_number, db-connection-string |
| Name is exactly one of these | pin, cvv, cvc, ssn, pwd, otp | PIN, Cvv, SSN, s_s_n, OTP |
The short names match only exactly, because they appear inside harmless words. Shipping, Spinner, Opinion, PinCode and OtpCode are not masked.
object checkout = new
{
ShippingAddress = "Amman",
Pin = "1234",
PinCode = "11183",
Otp = "998877",
RefreshToken = (string?)null,
};
string json = checkout.ToSafeJson();
// → "{\"ShippingAddress\":\"Amman\",\"Pin\":\"***\",\"PinCode\":\"11183\",\"Otp\":\"***\",\"RefreshToken\":null}"
Dictionary keys are treated like property names, which makes it easy to log HTTP headers:
Dictionary<string, string> headers = new Dictionary<string, string>
{
["Content-Type"] = "application/json",
["X-Api-Key"] = "abc",
["Set-Cookie"] = "sid=1",
};
string json = headers.ToSafeJson();
// → "{\"Content-Type\":\"application/json\",\"X-Api-Key\":\"***\",\"Set-Cookie\":\"***\"}"
Mask your own names
Pass more names to mask data that is sensitive in your domain, such as salaries or national IDs. These extra names are matched exactly, after the same normalization (case, _ and - ignored).
Employee employee = new Employee
{
Name = "Sara",
Salary = 5000,
BaseSalary = 4000,
NationalId = "9871234560",
};
string json = employee.ToSafeJson("Salary", "national_id");
// → "{\"Name\":\"Sara\",\"Salary\":\"***\",\"BaseSalary\":4000,\"NationalId\":\"***\"}"
BaseSalary stays visible because an extra name must match the whole property name.
Details
- The mask replaces the whole value, whatever its type. A number becomes the string
"***", and a sensitive property that holds an object is replaced as a whole:new { Url = "/api", Authorization = new { Scheme = "Bearer", Parameter = "abc" } }gives{"Url":"/api","Authorization":"***"}. - A sensitive property whose value is
nullstaysnull, so the log still shows that no value was sent. - The original object is not changed. Masking happens on a JSON copy.
- A
nullreceiver returns"null". A string receiver returns a JSON string:"hello".ToSafeJson()returns"\"hello\"". - The JSON uses the
System.Text.Jsondefaults: property names as declared, no indentation, non-ASCII text escaped. - A
nullarray foradditionalSensitiveNamesthrowsArgumentNullException(parameteradditionalSensitiveNames). Calling it with no extra names is fine. - A cycle in the object graph throws
JsonException.
ToSafeJson looks at property names and dictionary keys, not at values. A password written into a Notes or Description field, or a token inside a URL string, is logged as it is. Log a purpose-built DTO when the data is free text.
Canonical JSON and content hashes
ToCanonicalJson
ToCanonicalJson() serializes the object to one fixed form: property names sorted ordinally at every depth, no white space, invariant formatting and public properties only. Two objects with the same data produce the same text whatever order their properties are declared in. Use it when the JSON is compared, hashed or signed. Arrays keep their order, because order in an array is data.
string address = new { Zip = "11183", City = "Amman" }.ToCanonicalJson();
// → "{\"City\":\"Amman\",\"Zip\":\"11183\"}"
object customer = new
{
Name = "Ahmad",
Address = new { Street = "Rainbow", City = "Amman" },
Tags = new[] { "vip", "early" },
};
string json = customer.ToCanonicalJson();
// → "{\"Address\":{\"City\":\"Amman\",\"Street\":\"Rainbow\"},\"Name\":\"Ahmad\",\"Tags\":[\"vip\",\"early\"]}"
Sorting is ordinal, so upper-case letters come before lower-case letters. Dictionary keys are sorted the same way:
Dictionary<string, int> stock = new Dictionary<string, int> { ["b"] = 2, ["B"] = 1, ["a"] = 3 };
string json = stock.ToCanonicalJson();
// → "{\"B\":1,\"a\":3,\"b\":2}"
- A
nullreceiver returns"null". - Scalars use invariant formatting:
1.5.ToCanonicalJson()returns"1.5"on every machine culture. - Properties whose value is
nullare written asnull. They are part of the text and of the hash. - Property names are written as declared (no camelCase policy). Non-ASCII text is escaped as
\uXXXX, the same on every machine. - A cycle throws
JsonException, and a typeSystem.Text.Jsoncannot serialize throwsNotSupportedException.
System.Text.Json keeps the scale of a decimal. new { Total = 99.5m } gives {"Total":99.5}, but new { Total = 99.50m } gives {"Total":99.50}, so the two have different canonical JSON and a different ContentHash. If amounts can come from different sources (a database column and a parsed string, for example), bring them to one scale before you hash.
ContentHash
ContentHash() returns the lower-case hex SHA-256 of the object's UTF-8 canonical JSON: always 64 characters. Two objects with the same public property names and values get the same hash, whatever their runtime type, property order or dictionary implementation. Use it as an ETag, a cache key or a cheap "has this changed?" check.
public sealed class Invoice
{
public string Number { get; set; } = "";
public string Customer { get; set; } = "";
public decimal Amount { get; set; }
public DateTime? PaidAt { get; set; }
}
Invoice invoice = new Invoice { Number = "INV-2024-001", Customer = "Ahmad", Amount = 250m };
string hash = invoice.ContentHash();
// → "53242913c3dbe6f5c79efe3994efa6a6609e7182bf4978e31676c2aaa8a2f117"
string same = new { Customer = "Ahmad", PaidAt = (DateTime?)null, Amount = 250m, Number = "INV-2024-001" }.ContentHash();
// → "53242913c3dbe6f5c79efe3994efa6a6609e7182bf4978e31676c2aaa8a2f117"
The hash is based on content, not on the type. The anonymous object above has the same properties and values as the Invoice, so it has the same hash. An object without the PaidAt property has a different hash, because its canonical JSON has no "PaidAt":null.
Example: ETag for an API resource
Send the hash as an ETag. When the client sends it back in If-None-Match and the invoice has not changed, answer 304 Not Modified without a body.
app.MapGet("/api/v1/invoices/{number}", async (string number, HttpContext context, IInvoiceStore store) =>
{
Invoice? invoice = await store.FindAsync(number, context.RequestAborted);
if (invoice is null)
{
return Results.NotFound();
}
string etag = $"\"{invoice.ContentHash()}\"";
if (context.Request.Headers.IfNoneMatch == etag)
{
return Results.StatusCode(StatusCodes.Status304NotModified);
}
context.Response.Headers.ETag = etag;
return Results.Ok(invoice);
});
The same idea detects changes in a background job: store the hash of the last version you processed, and skip the record when record.ContentHash() still matches.
- A
nullreceiver hashes the literalnull:((object?)null).ContentHash()returns"74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b". - Every rule of
ToCanonicalJsonapplies, includingnullproperties and decimal scale. Changing any value changes the hash. - It throws the same exceptions as
ToCanonicalJson:JsonExceptionfor a cycle,NotSupportedExceptionfor a type that cannot be serialized.
If a property such as LastViewedAt changes without changing the meaning of the resource, mark it with [JsonIgnore] (from System.Text.Json.Serialization). The serializer skips it, so it does not change the hash. Or hash a DTO that holds only the fields the client sees.
ContentHash is a plain SHA-256 with no secret. It tells you whether content changed; it does not prove who produced it. To sign content, use ToHmacSha256 on the canonical JSON.
Trimming and native AOT
ToJsonString, FromJson<T>, ToSafeJson, ToCanonicalJson and ContentHash use reflection-based System.Text.Json serialization. They are marked with [RequiresUnreferencedCode] and [RequiresDynamicCode], so a trimmed or native AOT build shows warnings IL2026 and IL3050 where you call them, with this message:
This method uses reflection-based System.Text.Json serialization, which trimming and ahead-of-time compilation can break. Serialize with a source-generated JsonSerializerContext instead.
In a trimmed or AOT app, serialize with a source-generated JsonSerializerContext instead, for example JsonSerializer.Serialize(order, AppJsonContext.Default.Order). PrettyJson, MinifyJson and IsValidJson work on text with JsonDocument, carry no such attributes and are safe in trimmed and AOT builds.
More detail is on the Trimming and AOT page.
See also
- Encoding and validation: SHA-256, HMAC signatures and the other text validators.
- Objects:
ToQueryStringand other helpers that work on whole objects. - Compare, clone and map:
DeepClone(which also builds onSystem.Text.Json),ComparePropertiesandMapTo. - Trimming and AOT: which methods need reflection and what to use instead.