ZeroRedact: A Simple Redaction NuGet Package
ZeroRedact is a fast, simple, zero allocation redacting library for .NET. Read about how to easily redact discrete pieces of PII or other sensitive information in .NET.

Heya, I made a redacting NuGet package called ZeroRedact! Here are the quick links:
- GitHub
- NuGet
- Official Docs - see the most up to date info
- WASM demo site - test out the real NuGet package from your browser!
What does it do?
ZeroRedact redacts a given discrete piece of information. It's easy to use:
var redactor = new Redactor();
// returns "*************"
var result = redactor.RedactString("Personal data");
It's customisable:
var redactor = new Redactor();
var options = new StringRedactorOptions
{
RedactorType = StringRedaction.SecondHalf,
RedactionCharacter = '#'
};
// returns "Person#######"
var result = redactor.RedactString("Personal data", options);
And has a range of methods for specific types of sensitive information:
RedactString()
RedactEmailAddress()
RedactCreditCard()
RedactDate()
RedactPhoneNumber()
RedactIPv4Address()
RedactIPv6Address()
RedactMACAddress()
Where to use it?
Logging
Partial redaction in logging could help when identifying a problem without breaching privacy concerns. ZeroRedact can also integrate with Microsoft.Extensions.Compliance.Redaction for easy logging redaction.
Displaying
Perhaps a user needs to know which of their credit cards are being used for a billing transaction, showing the last four digits of their card will help them know which card will be charged.
Or if a user needs to receive a 2FA email or SMS, it's helpful to provide a hint of where they should look.
Why make it?
Looking at what's available
Looking around at redaction libraries in NuGet, I felt they fell into two categories:
- Simple, but incomplete. Similar to a single method you could write yourself.
- Complex. These take in entire documents to seek out sensitive data to redact.
ZeroRedact is a simple, but complete redaction library. It's designed to take in a discrete piece of sensitive information to redact. Simple because if you want to fully or partially redact an email address, you must pass in the email address, and only the email address.
Zero allocations
I enjoy .NET optimisations. Whether it's optimising for size or for speed. Often this goes hand in hand with no heap allocations and ZeroRedact is a great chance to exercise performance while also giving back to the community.
New versions are benchmarked using Benchmark.NET and the results are available in the benchmarks folder in the repo.
How does it work?
Redaction workhorse
string.Create()
is the heart of this project. Bringing together a brief window of pre-creation string mutability, spans, and pointers. For instance, let's look at this redaction:
var redactor = new Redactor();
var sensitiveInfo = "Hello, world!";
// returns "*******world!"
var firstHalfOptions = new StringRedactorOptions
{
RedactorType = StringRedaction.FirstHalf
};
var firstHalfRedaction = redactor.RedactString(sensitiveInfo, firstHalfOptions);
Ultimately this is the workhorse:
private unsafe string CreateFirstHalfStringRedaction(ReadOnlySpan<char> value, char redactionCharacter)
{
ref var valueRef = ref MemoryMarshal.GetReference(value);
var valuePtr = (IntPtr)Unsafe.AsPointer(ref valueRef);
var redactorState = new RedactorState
{
StartPointer = valuePtr,
RedactionCharacter = redactionCharacter
};
var result = string.Create(value.Length, redactorState, static (outputBuffer, state) =>
{
var inputSpan = new Span<char>(state.StartPointer.ToPointer(), outputBuffer.Length);
var halfLength = (int)Math.Ceiling(inputSpan.Length / 2d);
outputBuffer[..halfLength].Fill(state.RedactionCharacter);
inputSpan[halfLength..].CopyTo(outputBuffer[halfLength..]);
});
return result;
}
This structure of this code originally comes from dotnet/runtime#30175. There's a lot going on:
- We get a pointer to the underlying data
- The input string and the redaction character from the options are passed in via the
RedactorState
struct static
is added to the lambda to prevent unintentional capture of local variables (i.e. preventing closure allocations)- The rest is recreating a
Span<char>
from the pointer and copying the right characters from that to theoutputBuffer
- which is the final returned, redacted string.
The unsafe
keyword gives it away, but we have to be cautious here. For instance, if we manipulate the variable inputSpan
above, we're manipulating the underlying original value passed in. This is dangerous, and as a quick example:
string value = "Hello, World!";
var result = DangerousExample(value);
Console.WriteLine(value);
Console.WriteLine(result);
unsafe string DangerousExample(ReadOnlySpan<char> value)
{
Span<char> output = stackalloc char[13];
ref var outputRef = ref MemoryMarshal.GetReference(value);
var outputPtr = (IntPtr)Unsafe.AsPointer(ref outputRef);
var result = string.Create(value.Length, outputPtr, static (outputBuffer, ptr) =>
{
var input = new Span<char>(ptr.ToPointer(), outputBuffer.Length);
input.CopyTo(outputBuffer);
input[0] = 'Y'; // Dangerous!
});
return result;
}
The results end up as:

Where the original string is modified. This would be awful to leak changes out to what should be an immutable string that's not even in our context! A lot of care is put in to only read from those pointer driven spans within the lambda.
Project structure
Zooming out and looking at the project structure, a lot of the design comes from how System.Text.Json works; specifically the SerializeAsync()
and DeserializeAsync()
methods:
- The usage of partial classes for different de/serialization
- Having an options object (
JsonSerializerOptions
) for more fine grained control
I was originally on the fence on whether to have the Redactor
class as static
or not. It's static for the de/serialization inspiration work, but was it that way because that's best, or because developers were so used to Newtonsoft.Json that the devs at Microsoft kept that for familiarity? I ended up asking, and Immo Landwerth (terrajobst) answered:
the goal was to minimize ceremony to have simple one-liners. I believe we modeled it off ofNewtonsoft.Json
'sJsonConvert
API.
I didn't need to ride off the back of an existing de-facto redaction pattern, so I ended up opting for a regular class with an interface. This gives the users options for how they want to implement, swap out, and mock the redacting in their codebase. Big thanks to one of my friends for talking through these for-and-against-ideas for static
vs non-static 😁.
Convenient methods for email, phone numbers, etc, keeps the options strongly typed, and clearly indicates to the reader what type of information is to be redacted. Then convenient again, the overloads are often to this pattern:
string RedactString(string value)
string RedactString(string value, StringRedactorOptions redactorOptions)
ReadOnlySpan<char> RedactString(ReadOnlySpan<char> value)
ReadOnlySpan<char> RedactString(ReadOnlySpan<char> value, StringRedactorOptions redactorOptions)
string RedactStringInternal(ReadOnlySpan<char> value)
string RedactStringInternal(ReadOnlySpan<char> value, StringRedactorOptions options)
Where string
and ReadOnlySpan<char>
are explicitly returned or accepted with the option of an options object.
Safety
Considering this project deals could deal with PII and other sensitive data, it's prudent to ensure it works as expected. This is why, at the time of writing this, there are just under 3000 unit tests to exercise each method overload with all sorts of data.
To help with this, Coverlet + reportgenerator is used to create a code coverage report:

Unfortunately for me, with how I've written the redactors, it's impossible to hit some lines. If we look at the private method RedactEmailInternal()
due to using enums for the redaction type (and using each one in the switch expression), the final default line is never hit.

I could remove the default and handle the CS8524 warning:

But I'm happy with where it is for now, though who knows, it could change in the future.
Exceptions were a big design point. What happens if there is an exception when redacting? I wanted two things:
- Attempt to not interrupt the flow of the caller
- Return the strongest redaction if there is a problem
The second point is key. ZeroRedact doesn't know the context data is being redacted for. If the caller wants to request showing the last four digits of a credit card and for some reason the redaction process fails, the caller is returned a fixed length redaction because:
- Flow is not interrupted due to a redaction
- The sensitivity context of the data isn't known so return the safest thing. It might be a pain for the end user who sees the fixed length redaction, but better that than leaked data
However, exceptions to do with passing in invalid options objects will still occur. Whether that's when constructing a Redactor
object, or passing the options object to a redaction method.
Also along the lines of supply chain safety (and really, as this project is "simple"), ZeroRedact makes a point to not bring in any other dependencies.
Documentation
The documentation is hosted as a GitHub pages site and built using docfx.
Examples are also in the <remarks>
section for the redaction types. Shown by hovering over the enum:

<remarks>
example.Demo
A demo site is also available via the documentation site. This is a Blazor WebAssembly static site that uses the actual NuGet package, compiled to WebAssembly for example use in the browser. Allows users to try it out easily first.
Microsoft.Extensions.Compliance.Redaction
A later edit to this post, but ZeroRedact can integrate with Microsoft.Extensions.Compliance.Redaction. See the docs for more.
Misc
- Why the tests can't use
DateOnly
directly - Why the the data is written the way it is
Span<T>.Fill()
has really neat optimisations- The namespace for all exposed types are the same, it saves the user from doing all sorts of
using
s for the different types based on the internal folder structure - A struct is used to pass data into
string.Create()
because you can't use aref
struct
or astruct
with aSpan
(aref
struct
) inside because they can't go into generics. For a second I was using aValueTuple
too. - It's great that
string
has an implicit conversion toReadOnlySpan<char>
, meaning you can return a string from a method and it will be converted to aReadOnlySpan<char>
implicitly.- It does make me wonder if I need to have some of those method overloads
- Looking in .NET itself,
String.Concat()
has overloads for both - though they act differently. The string version doesn't call theReadOnlySpan<char>
version. - Searching for Stephen Toub's contributions, he also does overloads for
ReadOnlySpan<char>
Future
In the future, I'd like to have the following:
- Proper Unicode support. This is for characters that look like a single width character, but under the hood are n > 1 characters long, see more here. This messes with the redactor and I need to work out an allocationless way of doing this cleanly.
- More types of PII to support
- Looking to clean up how option layering works, as of 2.1.0 I'm not too happy with it.
To conclude
Thanks for dropping by and reading about ZeroRedact! If you like it, give it a star⭐on GitHub. I'm looking to continuously improve and maintain it over time so I hope you find great use out of it.