Standard Base64 and Base64URL are different alphabets
Standard Base64 (RFC 4648 section 4) uses A-Z a-z 0-9 + / and pads with =. Base64URL (section 5) replaces + with - and / with _, and padding is usually omitted. Convert.ToBase64String in .NET always produces the standard form; it has no URL-safe option. JWTs use Base64URL for their segments, which is why you never see + or / in a token.
What the two variants look like
Real output from Convert.ToBase64String and WebEncoders.Base64UrlEncode:
| Input | Standard Base64 | Base64URL |
|---|---|---|
user42:invoice?.pdf | dXNlcjQyOmludm9pY2U/LnBkZg== | dXNlcjQyOmludm9pY2U_LnBkZg |
??? | Pz8/ | Pz8_ |
bytes FB FF BF | +/+/ | -_-_ |
user42:report>final.pdf | dXNlcjQyOnJlcG9ydD5maW5hbC5wZGY= | dXNlcjQyOnJlcG9ydD5maW5hbC5wZGY |
The last row shows the point: many inputs encode to the same text in both variants, so a bug can be invisible until the first input that hits character 62 or 63 of the alphabet.
How often do + and / appear?
It depends entirely on what you encode:
- Random or binary bytes: in 20,000 random 18-byte values (24 Base64 characters), 53.6% contained a
+or/. Hashes, GUID bytes, random tokens and encrypted payloads behave like this, so for these the problem shows up almost immediately. - Plain ASCII text: in 20,000 synthetic keys of the form
userN:IMG_N.ext, none contained+or/. Letters and digits rarely produce them. Punctuation such as?can, as the table shows.
That is why bugs of this kind tend to appear "only sometimes": they depend on the data, and tests with simple names will pass.
What ASP.NET Core does with them
A local Kestrel server was started with a route /files/{key} and a query endpoint, and each variant was requested:
| Request | Response |
|---|---|
/files/abc+def | 200, key = abc+def (the + is kept in a path) |
/files/abc%2Bdef | 200, key = abc+def |
/files/abc/def | 404 (the / created an extra path segment) |
/files/abc%2Fdef | 200, key = abc%2Fdef (not decoded to a /) |
/files/abc== | 200, key = abc== |
/q?k=abc+def | value = abc def (+ became a space in the query string) |
/q?k=abc%2Bdef | value = abc+def |
Three practical consequences:
- A
/in a Base64 value used as a path segment makes the route miss (404), and the encoded form%2Fdoes not decode back to/, so your lookup by the original key fails. - A
+is harmless in a path in ASP.NET Core but turns into a space in a query string unless you percent-encode it. Other proxies, clients and servers may treat it differently, so do not rely on it. - Padding
=was accepted in a path here, but dropping it keeps URLs shorter and is the usual Base64URL convention.
The fix: use Base64URL
using Microsoft.AspNetCore.WebUtilities;
using System.Text;
// Encode: never produces +, / or =
static string ToUrlKey(string text) =>
WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(text));
// Decode
static string FromUrlKey(string key) =>
Encoding.UTF8.GetString(WebEncoders.Base64UrlDecode(key));
// Round trip: FromUrlKey(ToUrlKey("user42:invoice?.pdf")) == "user42:invoice?.pdf"
If you cannot reference Microsoft.AspNetCore.WebUtilities, the manual version produces the same alphabet (tested: the bytes FB FF BF give -_-_):
static string ManualUrlSafe(byte[] bytes) =>
Convert.ToBase64String(bytes)
.TrimEnd('=')
.Replace('+', '-')
.Replace('/', '_');
Newer .NET versions (.NET 9 and later) also include a built-in Base64Url class in System.Buffers.Text.
A simple rule
Use Convert.ToBase64String for binary data inside JSON, email or other non-URL contexts. Use Base64URL for anything that ends up in a URL, a file name, or a cookie value. If you paste a value into the Base64 Encoder and see + or / in the output, switch to the URL-safe form before using it in a link.
Update, 14 Oct 2026: an earlier version of this article told the topic as a first-person incident, claimed that a + in a URL path is decoded as a space, and quoted a 4% failure rate. None of that was based on a real event or a measurement, so the article has been rewritten around tested results.