OWASP A04: Cryptographic Failures (1) — Encoding, Hashing, Encryption and HTTPS
Why Base64 is not encryption and how hashing differs from encryption. We catch tampered data with AES-GCM, use RandomNumberGenerator instead of Random for password reset codes, see why MD5 is wrong for passwords, turn on the CA5351 and CA5394 analyzers, and inspect HTTPS redirection and HSTS defaults in ASP.NET Core with curl.
Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).
Guess First
In the Bookstore database you see the value Y2FyZCBlbmRpbmcgNDI0MiwgZGV2LW9ubHk= next to an order note. The colleague who wrote the code says "the card data is stored encrypted". Is that right? And if two users in the same table have exactly the same value in their password column, what does that tell you?
We will see the answer to both questions in code in this post. The topic is A04:2025 Cryptographic Failures. It was second in 2021, moved down to fourth in 2025 and covers 32 CWEs; the most typical are using a broken algorithm (CWE-327), insufficient entropy (CWE-331) and a weak random number generator (CWE-338). In this category cryptography is rarely "broken"; it is usually used in the wrong place, with the wrong tool, or not at all.
Run the examples on your own machine, against your own lab application. Only try these techniques on systems you own or have written permission to test.
Encoding, Hashing, Encryption: Three Different Jobs
These three words are often mixed up, yet each answers a different question. Let's put all three side by side in a small console application:
using System.Security.Cryptography;
using System.Text;
string cardNote = "card ending 4242, dev-only";
byte[] plain = Encoding.UTF8.GetBytes(cardNote);
// 1) Encoding: no secrecy, only the format changes
string encoded = Convert.ToBase64String(plain);
Console.WriteLine($"Base64 : {encoded}");
Console.WriteLine($"Decoded: {Encoding.UTF8.GetString(Convert.FromBase64String(encoded))}");
// 2) Hashing: one-way, the same input always gives the same output
string hash1 = Convert.ToHexString(SHA256.HashData(plain));
string hash2 = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(cardNote + "!")));
Console.WriteLine($"SHA-256: {hash1}");
Console.WriteLine($"SHA-256: {hash2}");
// 3) Encryption (AES-GCM): no way back without the key
byte[] key = RandomNumberGenerator.GetBytes(32);
byte[] nonce = RandomNumberGenerator.GetBytes(AesGcm.NonceByteSizes.MaxSize);
byte[] cipher = new byte[plain.Length];
byte[] tag = new byte[AesGcm.TagByteSizes.MaxSize];
using (AesGcm aes = new AesGcm(key, tag.Length))
{
aes.Encrypt(nonce, plain, cipher, tag);
}
Console.WriteLine($"Cipher : {Convert.ToHexString(cipher)}");
byte[] decrypted = new byte[cipher.Length];
using (AesGcm aes = new AesGcm(key, tag.Length))
{
aes.Decrypt(nonce, cipher, tag, decrypted);
}
Console.WriteLine($"Plain : {Encoding.UTF8.GetString(decrypted)}");
// Flip a single bit in the ciphertext
cipher[0] ^= 0x01;
try
{
using AesGcm aes = new AesGcm(key, tag.Length);
aes.Decrypt(nonce, cipher, tag, decrypted);
}
catch (CryptographicException ex)
{
Console.WriteLine($"Tampered: {ex.GetType().Name}: {ex.Message}");
}
Base64 : Y2FyZCBlbmRpbmcgNDI0MiwgZGV2LW9ubHk=
Decoded: card ending 4242, dev-only
SHA-256: 8A1FDC089A34E37C38845B27A15CF7756EEFCB2455F442DCAAC77AE9D6807751
SHA-256: 316636F6C9CC5CBA34BD69E6A4447E0DA7470AF9E9E96C8959D9055344A1C88C
Cipher : F7FA33ADA1147F51EE8CD3E8800BE23945060C5F45EBA2312E4F
Plain : card ending 4242, dev-only
Tampered: AuthenticationTagMismatchException: The computed authentication tag did not match the input authentication tag.
The value from the opening was Base64 and came back in one line. Encoding turns data into a format another system can carry; it has no key, and anyone can reverse it. That is why Base64 is not "encryption". Your colleague is not right.
Hashing is one-way. Adding a single ! to the input changed the entire output, but the same input always gives the same hash. It is great for integrity checks and not enough on its own for secrecy: an attacker can compute the hash of a guessable input too.
Encryption is a reversible transformation with a key. AES-GCM does two jobs here: it hides the content and protects integrity with the tag. When I flipped a single bit in the ciphertext, Decrypt did not silently return corrupted data; it threw an exception. This is "authenticated encryption", and it is what you look for when choosing encryption. One rule: never use the same nonce twice with the same key. Generate a new nonce for every encryption and store it with the ciphertext.
Randomness: Random vs RandomNumberGenerator
Think of this Bookstore endpoint that generates a six-digit code for password reset. In the lab we write the code to the log instead of sending an email; writing tokens to the log in a real application is a separate mistake.
app.MapPost("/password-reset", (ResetRequest request, ILogger<Program> logger) =>
{
// WEAK: time-based seed, same second means same token
Random random = new Random((int)DateTimeOffset.UtcNow.ToUnixTimeSeconds());
string token = random.Next(100_000, 999_999).ToString();
logger.LogInformation("Reset mail to {Email}: token {Token}", request.Email, token);
return Results.Accepted();
});
The problem has two layers. The first is the seed: the same seed produces the same sequence, and here the seed is the clock in seconds. In the lab, when I sent requests for two different email addresses at the same moment, both log lines had the same code; someone who requests a reset for their own account learns the code generated for someone else in the same second. The second is Random itself: even without a seed, it is not designed for cryptographic use. A third issue is the six digits: there are only about 900,000 possibilities, which is weak without rate limiting.
The fix is a single line. RandomNumberGenerator uses the operating system's cryptographic random number generator and, since .NET 8, can produce strings directly:
using System.Security.Cryptography;
var builder = WebApplication.CreateBuilder(args);
var app = builder.Build();
app.MapPost("/password-reset", (ResetRequest request, ILogger<Program> logger) =>
{
// 32 bytes = 256 bits, from the operating system's CSPRNG
string token = RandomNumberGenerator.GetHexString(64, lowercase: true);
logger.LogInformation("Reset mail to {Email}: token {Token}", request.Email, token);
return Results.Accepted();
});
app.Run();
record ResetRequest(string Email);
I sent the same two requests at the same moment again:
curl -s -X POST https://localhost:5124/password-reset -H "Content-Type: application/json" -d '{"email":"mallory@example.com"}'
curl -s -X POST https://localhost:5124/password-reset -H "Content-Type: application/json" -d '{"email":"ada@example.com"}'
info: Program[0]
Reset mail to ada@example.com: token 30d17a93bd6663dbc83ebffff3f3b476ce6e104b6afbcc9e2a453e79902d2dce
info: Program[0]
Reset mail to mallory@example.com: token 8da54cb228aa9e6fada84a9c2f26a013a833d6dad3be86c8d1ac1103306ef9ba
You don't have to catch this mistake in code review. .NET's security analyzers are off by default; adding an .editorconfig to the project turns them on:
root = true
[*.cs]
dotnet_diagnostic.CA5350.severity = warning
dotnet_diagnostic.CA5351.severity = warning
dotnet_diagnostic.CA5394.severity = warning
.../Program.cs(14,20): warning CA5394: Random is an insecure random number generator. Use cryptographically secure random number generators when randomness is required for security. (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5394)
Weak Algorithms: MD5 and SHA-1
On to the second question from the opening. Let's recreate a user table migrated from an old system that stores passwords with MD5:
using System.Diagnostics;
using System.Security.Cryptography;
using System.Text;
string[] users = ["ada", "alan", "grace"];
string[] passwords = ["123456", "123456", "Bookstore2026!"];
for (int i = 0; i < users.Length; i++)
{
byte[] md5 = MD5.HashData(Encoding.UTF8.GetBytes(passwords[i]));
Console.WriteLine($"{users[i],-6} {Convert.ToHexString(md5).ToLowerInvariant()}");
}
Stopwatch watch = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000; i++)
{
MD5.HashData(Encoding.UTF8.GetBytes($"guess{i}"));
}
Console.WriteLine($"1,000,000 MD5 hashes: {watch.ElapsedMilliseconds} ms");
ada e10adc3949ba59abbe56e057f20f883e
alan e10adc3949ba59abbe56e057f20f883e
grace 453a55a9ad763e2d11c33dfd63ca46c9
1,000,000 MD5 hashes: 232 ms
There are three lessons. The same password gives the same hash, so anyone looking at the table immediately sees that ada and alan use the same password. Because there is no salt, matching against precomputed tables works; that is why many developers recognize e10adc39... by heart. Most important is speed: on my machine (Apple M5, single thread) a million MD5 hashes took about 230 ms. For passwords a fast hash is not a feature but a vulnerability; it makes guessing just as fast. The analyzer catches this too:
.../Program.cs(10,18): warning CA5351: <Main>$ uses a broken cryptographic algorithm MD5 (https://learn.microsoft.com/dotnet/fundamentals/code-analysis/quality-rules/ca5351)
You may see MD5 and SHA-1 in non-security jobs such as file checksums, but in new code use SHA-256 or stronger for integrity. For passwords even SHA-256 is the wrong tool; the right answer is slow, salted algorithms. We will see that in the next post with ASP.NET Core Identity's PasswordHasher.
Data in Transit: HTTPS and HSTS
Even the best encryption is useless if data travels the network as plain text. Let's put the two lines from the ASP.NET Core template into the lab and run it in the Production environment:
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddHsts(options =>
{
options.MaxAge = TimeSpan.FromDays(365);
options.IncludeSubDomains = true;
// LAB ONLY: to see the header on localhost too
options.ExcludedHosts.Clear();
});
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseHsts();
}
app.UseHttpsRedirection();
app.MapGet("/health", () => Results.Ok(new { status = "ok" }));
app.Run();
curl -i http://localhost:5123/health
HTTP/1.1 307 Temporary Redirect
Content-Length: 0
Date: Fri, 06 Nov 2026 19:05:28 GMT
Server: Kestrel
Location: https://localhost:5124/health
curl -i https://localhost:5124/health
HTTP/2 200
content-type: application/json; charset=utf-8
date: Fri, 06 Nov 2026 19:05:43 GMT
server: Kestrel
strict-transport-security: max-age=31536000; includeSubDomains
{"status":"ok"}
UseHttpsRedirection redirects the HTTP request to HTTPS with a 307 by default. But the first request still went over plain HTTP; someone on the network can interfere with that first request. HSTS closes this gap: once the browser has seen the Strict-Transport-Security header, it never goes to that site over HTTP for the duration of max-age. The header is sent only in the HTTPS response; it is not in the 307 response over HTTP.
The defaults of UseHsts() are cautious: MaxAge is 30 days, IncludeSubDomains and Preload are off, and ExcludedHosts contains localhost. In the lab, without the ExcludedHosts.Clear() line the same request returned no header at all; don't assume HSTS is "not working" just because you can't see it on localhost. Extend the 30 days as you get to know your application, don't turn on IncludeSubDomains unless every subdomain is HTTPS, and choose preload knowing that it is hard to undo.
Data at Rest
Disk and database encryption (a cloud provider's disk encryption, SQL Server TDE and similar) protects against a stolen physical disk, but as long as the application can read the data, data leaked through SQL injection is plain text. So first ask "do I need to store this data at all?". That is OWASP's advice too: don't keep unnecessary sensitive data, and classify what you must keep. If you need to store something like a card number, encrypt it in the application layer and keep the key separate from the data. Where to keep that key is the topic of the next post.
Don't Roll Your Own Crypto
"I reverse the password and XOR it, nobody can crack that" gets said once in every team. Don't design your own algorithm, and don't build your own encryption protocol either. The high-level APIs in System.Security.Cryptography (AesGcm, RandomNumberGenerator, SHA256.HashData, Rfc2898DeriveBytes.Pbkdf2) and ASP.NET Core's Data Protection API already come with sound defaults. If you need to compare two hashes, use CryptographicOperations.FixedTimeEquals instead of ==; a normal comparison stops at the first differing byte, and the timing difference can leak information.
Common Pitfalls
The first is mistaking Base64 for encryption. A JWT's body, the Basic authentication header and config values stored as "secret" are often only Base64, and anyone can read them.
The second is using Random or Guid.NewGuid() for tokens, password reset codes, API keys or session ids. A Guid is designed to be unique, not unpredictable; security-relevant values should come from RandomNumberGenerator.
The third is ECB mode or a fixed IV/nonce. If you encrypt by hand with Aes.Create(), you own the choice of mode, padding and IV. Use AesGcm where possible and generate a new nonce for every encryption.
The fourth is turning off certificate validation. Returning true unconditionally from HttpClientHandler.ServerCertificateCustomValidationCallback is a classic line that gets added "temporarily" in development and ends up in Production.
Checklist
Use encoding only for transport, hashing for integrity and encryption for secrecy, and name all three correctly in code. Generate every security-relevant random value with RandomNumberGenerator. Turn on the CA5350, CA5351 and CA5394 analyzers with .editorconfig.
In Production keep UseHttpsRedirection and UseHsts on and choose the HSTS duration deliberately. Use an authenticated mode (AES-GCM) for encryption, never repeat a nonce, and don't disable certificate validation. Don't store sensitive data you don't need to store.
A Small Experiment
★☆☆ In the console example, replace the cipher[0] ^= 0x01; line with tag[0] ^= 0x01; and compare the result. Then encrypt the same text twice and see that the ciphertexts differ.
★★☆ Add the .editorconfig to the lab's password reset endpoint, build the weak version first and see the CA5394 warning, then switch to RandomNumberGenerator and confirm the warning is gone.
★★★ In the HSTS lab, remove the ExcludedHosts.Clear() line and see the header disappear. Then change the MaxAge and IncludeSubDomains values and watch how the header changes with curl -i.
In the next post we move to the second half of cryptographic failures: exactly how ASP.NET Core Identity's PasswordHasher stores a password, how it differs from OWASP's current recommendation, what the Data Protection API is for, and where secrets should live if not in code.