Encoding, Base64, Hex and URIs: UTF-8, BOM, Windows-1254, Base64Url, Uri and URL Encoding
Every crossing between string and bytes needs an agreed encoding: the wrong one silently breaks Turkish letters, a BOM adds an invisible character to a CSV header, and Base64 is not encryption. Using Windows-1254 data from Bookstore's old accounting system and its search links, we show Encoding, CodePagesEncodingProvider, Base64Url, ToHexStringLower, how the URL encoding APIs differ, QueryHelpers and the traps of Uri, all with real output.
Version note: The code in this post was built and run with .NET 10; the new API example in the Base64 section was run with the .NET 11 SDK.
Guess First
The catalog of Bookstore, the online bookstore used as the shared example across this site's series, has a book called "Şeker Portakalı". Guess the output of the three lines; for the third, also think about this: does this string protect a password?
Console.WriteLine(Title.Length);
Console.WriteLine(Encoding.UTF8.GetByteCount(Title));
Console.WriteLine(Convert.ToBase64String(Encoding.UTF8.GetBytes("Ada:example-password-change-me")));
15
17
QWRhOmV4YW1wbGUtcGFzc3dvcmQtY2hhbmdlLW1l
The 15-character title takes 17 bytes in UTF-8, because Ş and ı are written with two bytes each. If the limit of a database column or a protocol field is in bytes, a check based on the character count gives the wrong answer. The third line is the kind of value sent in an HTTP Basic authentication header, and it protects nothing: Base64 is an encoding, not encryption. Later in this post we decode the same string in one line.
In the first posts of the series we handled text as strings. In this post we look at the boundary between string and bytes: the Encoding class, the BOM, Windows-1254 data from old systems, Base64, Base64Url and hex, and finally URL encoding and Uri. The file and stream side (StreamReader's BOM detection, CSV files produced by Turkish Excel) is in the "Working with CSV" post of the I/O in .NET series; here we cover converting between byte[] and string and the API boundaries.
dotnet run --project samples/Bookstore.Bcl.Samples -- encoding-counts --culture en-US
Characters, Bytes and Encoding
In .NET a string is held in memory as UTF-16 code units. To write a string to a file, the network or a database you have to turn it into bytes with an encoding, and when reading you have to turn it back with the same encoding. I converted the same title to bytes with five encodings and read it back:
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Encoding[] encodings = [Encoding.UTF8, Encoding.Unicode, Encoding.Latin1, Encoding.ASCII, Encoding.GetEncoding(1254)];
Console.WriteLine($"\"{Title}\" has {Title.Length} chars");
foreach (Encoding encoding in encodings)
{
byte[] bytes = encoding.GetBytes(Title);
string back = encoding.GetString(bytes);
Console.WriteLine($"{encoding.WebName,-12} {encoding.GetByteCount(Title),3} bytes {Convert.ToHexString(bytes[..4]),-9}... round-trip: {back}");
}
"Şeker Portakalı" has 15 chars
utf-8 17 bytes C59E656B ... round-trip: Şeker Portakalı
utf-16 30 bytes 5E016500 ... round-trip: Şeker Portakalı
iso-8859-1 15 bytes 53656B65 ... round-trip: Seker Portakali
us-ascii 15 bytes 3F656B65 ... round-trip: ?eker Portakal?
windows-1254 15 bytes DE656B65 ... round-trip: Şeker Portakalı
UTF-8 keeps ASCII characters in one byte and Turkish letters in two, and covers all of Unicode; it's the default choice for files, JSON, HTTP and databases. Despite its name, Encoding.Unicode is UTF-16 little-endian and spends at least two bytes per character. The real lesson is in the last three lines. Latin-1 (iso-8859-1) has no Ş or ı; .NET turned them into S and i without an error, and reading back left "Seker Portakali". ASCII turned the same characters into ?. The data was silently damaged and there's no exception. Windows-1254 keeps Turkish letters in a single byte, hence 15 bytes and a correct round trip.
Encoding.GetEncoding(1254) doesn't work on .NET Core by default; Windows-specific code pages are available only after registering CodePagesEncodingProvider. The provider ships in .NET's shared framework, so you don't need to add a package; calling Encoding.RegisterProvider once at startup is enough. If you don't register it, you get a NotSupportedException or an ArgumentException.
The BOM: Three Invisible Bytes
UTF-8 files may start with the bytes EF BB BF. For UTF-8 these bytes carry no byte order information; they are just a "this file is UTF-8" marker. Excel and some Windows tools expect it, Unix tools generally don't. Let's write the order file that goes to accounting with a BOM and read it back as bytes:
byte[] preamble = Encoding.UTF8.GetPreamble();
Console.WriteLine($"Encoding.UTF8 preamble: {Convert.ToHexString(preamble)} ({preamble.Length} bytes)");
Console.WriteLine($"GetBytes adds BOM: {Encoding.UTF8.GetBytes("A").Length == 4}");
File.WriteAllText(path, "OrderNumber,Total\n", new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
byte[] fromExcel = File.ReadAllBytes(path);
Console.WriteLine($"File starts with: {Convert.ToHexString(fromExcel[..6])}");
string naive = Encoding.UTF8.GetString(fromExcel);
string header = naive.Split(',')[0];
Console.WriteLine($"GetString header: \"{header}\" length={header.Length} == \"OrderNumber\": {header == "OrderNumber"}");
Console.WriteLine($"First char: U+{(int)header[0]:X4}");
string viaReader = File.ReadAllText(path);
Console.WriteLine($"File.ReadAllText header == \"OrderNumber\": {viaReader.Split(',')[0] == "OrderNumber"}");
ReadOnlySpan<byte> body = fromExcel.AsSpan().StartsWith(preamble) ? fromExcel.AsSpan(preamble.Length) : fromExcel;
Console.WriteLine($"Skip preamble, then GetString: {Encoding.UTF8.GetString(body).Split(',')[0] == "OrderNumber"}");
Encoding.UTF8 preamble: EFBBBF (3 bytes)
GetBytes adds BOM: False
File starts with: EFBBBF4F7264
GetString header: "OrderNumber" length=12 == "OrderNumber": False
First char: U+FEFF
File.ReadAllText header == "OrderNumber": True
Skip preamble, then GetString: True
The "OrderNumber" in the output looks right on screen, but its length is 12: there's an invisible U+FEFF character in front of it. Encoding.GetString doesn't recognize the BOM; it decodes it as part of the text. Import code that looks up the "OrderNumber" column by name can't find it, and everything looks normal in the error message. File.ReadAllText and StreamReader, on the other hand, detect and drop the BOM. The rule: if the byte array comes from a file or an HTTP body, either use a reader or skip the preamble yourself. Choose deliberately when writing too: the Encoding.UTF8 instance adds a BOM when writing to a stream, new UTF8Encoding(false) doesn't, and the overload of File.WriteAllText that takes no encoding writes UTF-8 without a BOM. Don't decide without knowing what the other side expects (Excel, or a Linux script).
Bookstore: Windows-1254 from the Old Accounting System
Bookstore's accounting integration still receives customer name fields from an old system. The system was written for Turkish Windows and stores text in the Windows-1254 code page. Reading the same bytes with different encodings:
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
Encoding turkish = Encoding.GetEncoding(1254);
// Customer name field exported by the legacy accounting system (Windows-1254).
byte[] legacy = [0xDE, 0x65, 0x6B, 0x65, 0x72, 0x20, 0x50, 0x6F, 0x72, 0x74, 0x61, 0x6B, 0x61, 0x6C, 0xFD];
Console.WriteLine($"as 1254 : {turkish.GetString(legacy)}");
Console.WriteLine($"as UTF-8 : {Encoding.UTF8.GetString(legacy)}");
Console.WriteLine($"as Latin1: {Encoding.Latin1.GetString(legacy)}");
byte[] utf8 = Encoding.UTF8.GetBytes(Title);
Console.WriteLine($"UTF-8 bytes read as 1254: {turkish.GetString(utf8)}");
byte[] converted = Encoding.Convert(turkish, Encoding.UTF8, legacy);
Console.WriteLine($"Encoding.Convert 1254 -> UTF-8: {legacy.Length} -> {converted.Length} bytes, {Encoding.UTF8.GetString(converted)}");
as 1254 : Şeker Portakalı
as UTF-8 : �eker Portakal�
as Latin1: Þeker Portakalý
UTF-8 bytes read as 1254: Åeker Portakalı
Encoding.Convert 1254 -> UTF-8: 15 -> 17 bytes, Şeker Portakalı
Almost every Turkish character problem is one of these four lines, and you can tell which one by looking at the output. If you see �, that is the replacement character U+FFFD, and 1254 data was read as UTF-8; UTF-8 considers these bytes invalid and puts this character in their place. If you see Þ and ý, the same data was read as Latin-1 (or Windows-1252); the same byte values map to Icelandic letters in that table. If you see two-character groups such as ı and Å, the opposite happened: UTF-8 data was read with a single-byte code page and every Turkish letter split into two characters (there's also an invisible control character after Å). Encoding.Convert converts a byte array directly from one encoding to another; the healthiest approach is to convert the field from the old system into a UTF-8 string right at our boundary and carry only that inside.
Silent damage by default is dangerous; to catch bad data before it enters the system you can use an exception fallback:
var strictUtf8 = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true);
try
{
strictUtf8.GetString(legacy);
}
catch (DecoderFallbackException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
Encoding strictAscii = Encoding.GetEncoding("us-ascii", EncoderFallback.ExceptionFallback, DecoderFallback.ExceptionFallback);
Console.WriteLine($"ASCII (default fallback): {Encoding.ASCII.GetString(Encoding.ASCII.GetBytes(Title))}");
try
{
strictAscii.GetBytes(Title);
}
catch (EncoderFallbackException ex)
{
Console.WriteLine($"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
DecoderFallbackException: Unable to translate bytes [DE] at index 0 from specified code page to Unicode.
ASCII (default fallback): ?eker Portakal?
EncoderFallbackException: Unable to translate Unicode character \\u015E at index 0 to specified code page.
An exception that tells you which byte at which index caused the problem is far more useful than seeing Portakal� in the log of an import job. If you import line by line, writing the bad line to a separate file and continuing is usually a better business rule than rejecting the whole file.
Base64, Base64Url and Hex
Base64 turns arbitrary bytes into text containing only letters, digits, +, / and =; every 3 bytes become 4 characters. Its purpose is to carry bytes over a channel that carries text (JSON, email, HTTP headers). It provides no confidentiality:
string basic = Convert.ToBase64String(Encoding.UTF8.GetBytes("Ada:example-password-change-me"));
Console.WriteLine($"Base64 : {basic}");
Console.WriteLine($"Decoded: {Encoding.UTF8.GetString(Convert.FromBase64String(basic))}");
byte[] token = [0xFB, 0xEF, 0xFF, 0x3E, 0x3F, 0x00, 0x10];
string standard = Convert.ToBase64String(token);
string urlSafe = Base64Url.EncodeToString(token);
Console.WriteLine($"Base64 : {standard}");
Console.WriteLine($"Base64Url : {urlSafe}");
Console.WriteLine($"Round-trip: {Base64Url.DecodeFromChars(urlSafe).AsSpan().SequenceEqual(token)}");
Console.WriteLine($"Standard Base64 in a query string: {Uri.EscapeDataString(standard)}");
try
{
Convert.FromBase64String(urlSafe);
}
catch (FormatException ex)
{
Console.WriteLine($"FromBase64String(Base64Url) -> {ex.GetType().Name}: {ex.Message}");
}
Base64 : QWRhOmV4YW1wbGUtcGFzc3dvcmQtY2hhbmdlLW1l
Decoded: Ada:example-password-change-me
Base64 : ++//Pj8AEA==
Base64Url : --__Pj8AEA
Round-trip: True
Standard Base64 in a query string: %2B%2B%2F%2FPj8AEA%3D%3D
FromBase64String(Base64Url) -> FormatException: The input is not a valid Base-64 string as it contains a non-base 64 character, more than two padding characters, or an illegal character among the padding characters.
The first two lines answer the question at the start of the post: the user name and password in Basic authentication come back with a single method call; the protection comes from TLS, not from Base64. If a value really needs to be hidden you need encryption, and if its integrity needs protecting you need an HMAC; both are in the Cryptography for Developers series.
The +, / and = of standard Base64 have special meaning in URLs; put into a query string they turn into something like %2B%2B%2F%2F..., longer and open to wrong decoding (some servers read + as a space). System.Buffers.Text.Base64Url, added in .NET 9, uses the URL-safe alphabet from RFC 4648: - instead of +, _ instead of /, and no padding. That's the right choice for an order tracking link or an email verification token. The two formats aren't interchangeable; passing a Base64Url value to Convert.FromBase64String gives you a FormatException.
Hex: ToHexString and ToHexStringLower
Hex is used to read hash values, file signatures or byte dumps; every byte becomes two characters:
byte[] hash = System.Security.Cryptography.SHA256.HashData(Encoding.UTF8.GetBytes("9780441172719"));
Console.WriteLine($"ToHexString : {Convert.ToHexString(hash[..8])}");
Console.WriteLine($"ToHexStringLower : {Convert.ToHexStringLower(hash[..8])}");
Console.WriteLine($"BitConverter : {BitConverter.ToString(hash[..8])}");
Console.WriteLine($"FromHexString : {Convert.FromHexString("DE656B6572").Length} bytes, {Encoding.GetEncoding(1254).GetString(Convert.FromHexString("DE656B6572"))}");
Console.WriteLine($"255.ToString(\"x2\"): {255.ToString("x2")}");
ToHexString : 9B07AAACA15F6202
ToHexStringLower : 9b07aaaca15f6202
BitConverter : 9B-07-AA-AC-A1-5F-62-02
FromHexString : 5 bytes, Şeker
255.ToString("x2"): ff
Convert.ToHexString has existed since .NET 5 and produces uppercase. For lowercase we used to write ToHexString(...).ToLowerInvariant() and allocate a second string; ToHexStringLower in .NET 9 does it in one step. Values such as ETags, file digests or Git commit ids are usually compared in lowercase. The hyphenated output of BitConverter.ToString is for human reading only; Convert.FromHexString doesn't accept it.
.NET 11: string and char APIs on the Base64 class
The System.Buffers.Text.Base64 class used to work only with UTF-8 bytes. .NET 11 added string and char based methods to it, as in Base64Url: EncodeToString, EncodeToChars, DecodeFromChars, EncodeToUtf8 and DecodeFromUtf8. There are both convenient overloads that allocate and allocation-free overloads that take spans. In the Bookstore.Bcl.Net11 project:
string encoded = System.Buffers.Text.Base64.EncodeToString(token);
byte[] decoded = System.Buffers.Text.Base64.DecodeFromChars(encoded);
Console.WriteLine($"Base64.EncodeToString : {encoded}");
Console.WriteLine($"Base64.DecodeFromChars: {Convert.ToHexString(decoded)}");
Console.WriteLine($"Same as Convert : {encoded == Convert.ToBase64String(token)}");
Base64.EncodeToString : ++//Pj8AEA==
Base64.DecodeFromChars: FBEFFF3E3F0010
Same as Convert : True
The result is the same as Convert.ToBase64String; the difference is the shape of the API. Base64 and Base64Url are now used with the same method names, so switching between the two formats is as easy as changing one class name.
URL Encoding and Building Query Strings
In a URL, spaces, #, &, ?, / and non-ASCII characters either have special meaning or can't appear at all, so values are written with percent-encoding (%C5%9E). .NET has three separate APIs, and their results aren't the same:
string[] inputs = [Title, "C# in Depth", "Dune & Children of Dune", "50% off/new?"];
Console.WriteLine($"{"input",-26}{"Uri.EscapeDataString",-50}{"WebUtility.UrlEncode",-46}HttpUtility.UrlEncode");
foreach (string input in inputs)
Console.WriteLine($"{input,-26}{Uri.EscapeDataString(input),-50}{WebUtility.UrlEncode(input),-46}{HttpUtility.UrlEncode(input)}");
input Uri.EscapeDataString WebUtility.UrlEncode HttpUtility.UrlEncode
Şeker Portakalı %C5%9Eeker%20Portakal%C4%B1 %C5%9Eeker+Portakal%C4%B1 %c5%9eeker+Portakal%c4%b1
C# in Depth C%23%20in%20Depth C%23+in+Depth C%23+in+Depth
Dune & Children of Dune Dune%20%26%20Children%20of%20Dune Dune+%26+Children+of+Dune Dune+%26+Children+of+Dune
50% off/new? 50%25%20off%2Fnew%3F 50%25+off%2Fnew%3F 50%25+off%2fnew%3f
Uri.EscapeDataString follows RFC 3986: a space becomes %20, hex is uppercase. WebUtility.UrlEncode and HttpUtility.UrlEncode apply the rule for HTML forms, application/x-www-form-urlencoded: a space becomes +. HttpUtility also writes hex in lowercase. All three decode correctly on the server, but in a path segment + isn't a space, and in signed URLs (compared byte by byte) a case difference breaks the signature. Use Uri.EscapeDataString as the default; if you're producing a form body, FormUrlEncodedContent does this for you.
The most common bug in code that builds links to Bookstore's search page is string concatenation. I tried it with three book titles:
const string baseUrl = "https://bookstore.example.com/search";
string[] queries = [Title, "C# in Depth", "Dune & Children of Dune"];
foreach (string q in queries)
{
string concatenated = $"{baseUrl}?q={q}&lang=tr";
var parsed = new Uri(concatenated);
string? qValue = HttpUtility.ParseQueryString(parsed.Query)["q"];
Console.WriteLine($"concat : {concatenated}");
Console.WriteLine($" server sees q=\"{qValue}\", fragment=\"{parsed.Fragment}\"");
string built = QueryHelpers.AddQueryString(baseUrl, new Dictionary<string, string?> { ["q"] = q, ["lang"] = "tr" });
string? builtValue = HttpUtility.ParseQueryString(new Uri(built).Query)["q"];
Console.WriteLine($"helper : {built}");
Console.WriteLine($" server sees q=\"{builtValue}\"");
}
concat : https://bookstore.example.com/search?q=Şeker Portakalı&lang=tr
server sees q="Şeker Portakalı", fragment=""
helper : https://bookstore.example.com/search?q=%C5%9Eeker%20Portakal%C4%B1&lang=tr
server sees q="Şeker Portakalı"
concat : https://bookstore.example.com/search?q=C# in Depth&lang=tr
server sees q="C", fragment="#%20in%20Depth&lang=tr"
helper : https://bookstore.example.com/search?q=C%23%20in%20Depth&lang=tr
server sees q="C# in Depth"
concat : https://bookstore.example.com/search?q=Dune & Children of Dune&lang=tr
server sees q="Dune ", fragment=""
helper : https://bookstore.example.com/search?q=Dune%20%26%20Children%20of%20Dune&lang=tr
server sees q="Dune & Children of Dune"
The first example works by accident: Uri encoded the non-ASCII characters and the space itself. In the second, # was taken as the start of the fragment; the server got only "C", and the lang parameter never arrived (the fragment stays in the browser and isn't sent to the server). In the third, & was taken as a parameter separator and the search became "Dune ". Concatenating user input into a URL is also a security problem: by adding &admin=true or # to the input, someone can change the URL's meaning. QueryHelpers.AddQueryString encodes every value with the Uri.EscapeDataString rule and appends it to any existing query string with the right separator. Since it ships in the ASP.NET Core shared framework (Microsoft.AspNetCore.WebUtilities), we added <FrameworkReference Include="Microsoft.AspNetCore.App" /> to the sample console project; a Web API project already has it.
Uri and UriBuilder
Uri parses an address, splits it into parts and normalizes it. A few behaviors are worth knowing:
var uri = new Uri("https://bookstore.example.com/search?q=%C5%9Eeker%20Portakal%C4%B1&lang=tr");
Console.WriteLine($"ToString() : {uri}");
Console.WriteLine($"AbsoluteUri : {uri.AbsoluteUri}");
Console.WriteLine($"OriginalString : {uri.OriginalString}");
Console.WriteLine($"Host={uri.Host} Path={uri.AbsolutePath} Query={uri.Query}");
var relative = new Uri(new Uri("https://bookstore.example.com/books/"), "../authors/ada");
Console.WriteLine($"Combine : {relative}");
Console.WriteLine($"TryCreate(\"bookstore.example.com/books\"): {Uri.TryCreate("bookstore.example.com/books", UriKind.Absolute, out _)}");
Console.WriteLine($"TryCreate(\"javascript:alert(1)\"): {Uri.TryCreate("javascript:alert(1)", UriKind.Absolute, out Uri? js)} scheme={js?.Scheme}");
var builder = new UriBuilder("https", "bookstore.example.com")
{
Path = "books/kurk-mantolu-madonna",
Query = $"ref={Uri.EscapeDataString("newsletter 11/2026")}",
};
Console.WriteLine($"UriBuilder: {builder.Uri.AbsoluteUri}");
ToString() : https://bookstore.example.com/search?q=Şeker Portakalı&lang=tr
AbsoluteUri : https://bookstore.example.com/search?q=%C5%9Eeker%20Portakal%C4%B1&lang=tr
OriginalString : https://bookstore.example.com/search?q=%C5%9Eeker%20Portakal%C4%B1&lang=tr
Host=bookstore.example.com Path=/search Query=?q=%C5%9Eeker%20Portakal%C4%B1&lang=tr
Combine : https://bookstore.example.com/authors/ada
TryCreate("bookstore.example.com/books"): False
TryCreate("javascript:alert(1)"): True scheme=javascript
UriBuilder: https://bookstore.example.com/books/kurk-mantolu-madonna?ref=newsletter%2011%2F2026
ToString() undoes the encoding and produces readable text; since it contains spaces, it's no longer a valid URL. Use AbsoluteUri when putting a URL into a log, an email or another request. To combine a relative address with a base address, the new Uri(baseUri, relative) overload also resolves .. segments; remember that the trailing / on the base address changes the result. TryCreate doesn't accept an address without a scheme as absolute, but it does consider a value with the javascript: scheme a valid URI. When validating a "website" or "return address" field coming from users, if you don't also check that the scheme is Uri.UriSchemeHttps, you open an XSS hole the moment you put that value into an href; the topic is covered in the XSS post of the Web Security series. .NET 11 also added the Uri.UriSchemeData constant for the data: scheme. UriBuilder lets you build an address by giving the parts separately, but it doesn't encode the value you put into its Query property; you still need to encode parameters with Uri.EscapeDataString.
In Production
Decide the encoding once, at the edge of the system. The encoding of every byte array coming from outside (a file, an HTTP body, a queue message, an old system's field) should be part of the contract, and it should be turned into a string at that boundary. Only string should travel inside, and a single encoding (UTF-8) should be used on the way out. Writing code that "detects the encoding automatically" is tempting, but there's no reliable way to tell single-byte code pages such as 1254 and 1252 apart by looking at the bytes.
Check length limits in the right unit. In SQL Server, nvarchar(200) means 200 UTF-16 code units, while varchar(200) means 200 bytes depending on the collation; a 200-character Turkish title may not fit into a varchar column with a UTF-8 collation. If an HTTP header or a protocol field sets a byte limit, check it with Encoding.UTF8.GetByteCount.
Always build URLs with a builder and write AbsoluteUri to logs. Carry tokens and signed links with Base64Url; if you have to put Base64 in a URL, pass it through Uri.EscapeDataString first. Small mistakes at these boundaries turn into bugs that show up only for certain book titles or certain customers and are hard to reproduce.
Common Pitfalls
Thinking Base64 is encryption. A Base64 value is decoded in one line; a Basic authentication header, an "encrypted" field written to a log or an id in a URL provides no confidentiality.
Adding parameters to a URL by string concatenation. A book title with # and & breaks the search, and malicious input changes the URL's meaning. Use QueryHelpers.AddQueryString or Uri.EscapeDataString.
The BOM breaking a CSV header. Encoding.GetString leaves the BOM as a U+FEFF character, the first column's name doesn't match, and the error message looks normal. Use a reader or skip the preamble.
Writing Turkish text with Latin-1 or ASCII. No exception occurs; Ş silently becomes S or ?. If you need a lossless conversion, use an exception fallback.
Using Uri.ToString() as a URL. It undoes the encoded characters and produces an invalid address; use AbsoluteUri.
A Small Experiment
★☆☆ Add a line to the encoding-1254 example that reads the same bytes with Encoding.GetEncoding(1252). Compare it with the Latin-1 output and find the character that differs.
★★☆ Write a LegacyFieldReader for Bookstore: it should turn a 1254-encoded byte array into a string with an exception fallback and, on error, log it with the line number and return null. Try it with test data containing a bad byte.
★★★ Design a token format for the order tracking link: a 16-byte random value (RandomNumberGenerator) and the order number, encoded together with Base64Url. Write two methods that produce and decode the token, add it to the link with QueryHelpers.AddQueryString and verify that no character of the link gets encoded again. Look at the HMAC post in the Cryptography series to find what you need to add to prevent the token from being tampered with.
After bytes we move to time. In the next post we'll see how TimeZoneInfo works with different ids on Windows and Linux ("Turkey Standard Time" and "Europe/Istanbul"), ParseExact and DateTimeStyles, turning dates into text and reading them back without loss, business day and end-of-month calculations, and using TimeProvider in tests.