Skip to main content

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​

MethodWhat it does
ToJsonStringSerializes any object to JSON, using its runtime type
FromJson<T>Deserializes a JSON string to T
PrettyJsonRe-formats JSON text with two-space indentation
MinifyJsonRemoves all insignificant white space from JSON text
IsValidJsonChecks whether text is valid JSON (false for null, empty or white space)
ToSafeJsonSerializes an object with sensitive values (Password, ApiKey, Cvv, ...) replaced by "***"
ToCanonicalJsonSerializes with property names sorted at every depth and no white space
ContentHashLower-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.

Serialize an order
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 null receiver returns the JSON literal "null"; it does not throw.
  • Exceptions from System.Text.Json pass through unchanged: a cycle in the object graph throws JsonException, and a type the serializer cannot handle throws NotSupportedException.
  • With the default options, non-ASCII text is escaped. new { City = "عمّان" }.ToJsonString() gives {"City":"\u0639\u0645\u0651\u0627\u0646"}. Run the result through PrettyJson or MinifyJson when 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.

Read an order from a request body
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:

Read camelCase JSON
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 }
Name mismatches do not throw

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 null string throws ArgumentNullException (parameter json).
  • Text that is not valid JSON, or does not fit T, throws JsonException. For example, "{\"Id\":\"abc\"}".FromJson<Order>() fails with the path $.Id in the message.
  • The JSON literal null returns null for a reference type or a nullable value type ("null".FromJson<int?>() returns null). For a non-nullable value type it throws JsonException: "null".FromJson<int>() does not return 0.
  • To check a payload without catching exceptions, call IsValidJson first.

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.

Indent a stored invoice
string stored = "{\"invoice\":\"INV-2024-001\",\"customer\":\"أحمد\",\"lines\":[{\"sku\":\"A-1\",\"qty\":2}],\"note\":\"<b>VIP</b>\"}";

string readable = stored.PrettyJson();
Output
{
"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.Json default 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.

Compact a configuration payload
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"}.

Exceptions for PrettyJson and MinifyJson
  • A null string throws ArgumentNullException (parameter json).
  • 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 throws JsonException, 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.

Check incoming payloads
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 return false; 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 returns true, even though PrettyJson and MinifyJson throw 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.

Log a login request
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:

Mask secrets at any depth
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.

RuleNamesMasked examples
Name contains one of thesepassword, passwd, secret, token, apikey, privatekey, authorization, cookie, creditcard, cardnumber, connectionstringUserPassword, clientSecret, access_token, RefreshToken, X-Api-Key, private-key, SetCookie, credit_card_number, db-connection-string
Name is exactly one of thesepin, cvv, cvc, ssn, pwd, otpPIN, 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.

Short names match exactly
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:

Log request 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).

Mask salary and national ID
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 null stays null, so the log still shows that no value was sent.
  • The original object is not changed. Masking happens on a JSON copy.
  • A null receiver returns "null". A string receiver returns a JSON string: "hello".ToSafeJson() returns "\"hello\"".
  • The JSON uses the System.Text.Json defaults: property names as declared, no indentation, non-ASCII text escaped.
  • A null array for additionalSensitiveNames throws ArgumentNullException (parameter additionalSensitiveNames). Calling it with no extra names is fine.
  • A cycle in the object graph throws JsonException.
Masking is based on names only

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.

Same data, same text
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:

Ordinal key order
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 null receiver returns "null".
  • Scalars use invariant formatting: 1.5.ToCanonicalJson() returns "1.5" on every machine culture.
  • Properties whose value is null are written as null. 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 type System.Text.Json cannot serialize throws NotSupportedException.
Decimal scale is part of the text

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.

Fingerprint an invoice
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.

Conditional GET in ASP.NET Core
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 null receiver hashes the literal null: ((object?)null).ContentHash() returns "74234e98afe7498fb5daf1f36ac2d78acc339464f950703b8c019892f982b90b".
  • Every rule of ToCanonicalJson applies, including null properties and decimal scale. Changing any value changes the hash.
  • It throws the same exceptions as ToCanonicalJson: JsonException for a cycle, NotSupportedException for a type that cannot be serialized.
Leave volatile fields out

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.

Not a security signature

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:

Warning 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​