OWASP A04: Cryptographic Failures (2) — Password Storage, Data Protection and Secret Management
How exactly does ASP.NET Core Identity's PasswordHasher store a password? We decode the hash header to see PBKDF2-HMAC-SHA512 with 100,000 iterations, move to OWASP's 220,000 with SuccessRehashNeeded, build a signed, time-limited share link with the Data Protection API, and keep secrets out of the code with user-secrets and environment variables.
Version note: This post follows the OWASP Top 10:2025; the code was tested with ASP.NET Core 10 (.NET 10).
Guess First
When you look at the AspNetUsers table of a project that uses ASP.NET Core Identity, almost every value in the PasswordHash column starts with AQAAAAIAAYagAAAAE. What does this common prefix tell you? If you hash the same password twice, do you get the same value? And does Identity's default setting meet today's OWASP recommendation?
In the previous post we saw why MD5 is wrong for passwords. In this post we are in the second half of A04:2025 Cryptographic Failures: storing passwords correctly, the Data Protection API for protecting data inside the application, and keeping secrets out of the code. Run the examples only on your own machine; only try these techniques on systems you own or have written permission to test.
The Right Way to Store Passwords
You don't encrypt passwords, you hash them; but not with just any hash. A password hash needs three properties. A random salt per user, so the same password doesn't produce the same value and precomputed tables are useless. A deliberately slow algorithm, so every guess is expensive for the attacker. And a tunable cost, so you can raise it as hardware gets faster.
The current order in the OWASP Password Storage Cheat Sheet is: first choice Argon2id (at least 19 MiB of memory, 2 iterations, parallelism 1), then scrypt (N=2^17, r=8, p=1), bcrypt for legacy systems (work factor at least 10; input limited to 72 bytes), and PBKDF2 when FIPS compliance is required. The recommended PBKDF2 iteration counts are 600,000 with HMAC-SHA-256 and 220,000 with HMAC-SHA-512.
Opening Up Identity's PasswordHasher
You don't need all of Identity to use its hasher; the Microsoft.Extensions.Identity.Core package is enough. A small console application:
dotnet new console -o PasswordLab -f net10.0
cd PasswordLab
dotnet add package Microsoft.Extensions.Identity.Core
using System.Buffers.Binary;
using System.Diagnostics;
using Microsoft.AspNetCore.Identity;
using Microsoft.Extensions.Options;
AppUser ada = new AppUser("ada");
string password = "correct horse battery staple";
PasswordHasher<AppUser> defaultHasher = new PasswordHasher<AppUser>();
string hash1 = defaultHasher.HashPassword(ada, password);
string hash2 = defaultHasher.HashPassword(ada, password);
Console.WriteLine(hash1);
Console.WriteLine(hash2);
PrintHeader(hash1);
PasswordHasher<AppUser> strongHasher = new PasswordHasher<AppUser>(
Options.Create(new PasswordHasherOptions { IterationCount = 220_000 }));
Console.WriteLine(strongHasher.VerifyHashedPassword(ada, hash1, password));
Console.WriteLine(strongHasher.VerifyHashedPassword(ada, hash1, "wrong password"));
string hash3 = strongHasher.HashPassword(ada, password);
PrintHeader(hash3);
Console.WriteLine(strongHasher.VerifyHashedPassword(ada, hash3, password));
Measure("100k", defaultHasher, ada, password);
Measure("220k", strongHasher, ada, password);
static void PrintHeader(string hash)
{
byte[] bytes = Convert.FromBase64String(hash);
uint prf = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(1, 4));
uint iterations = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(5, 4));
uint saltBytes = BinaryPrimitives.ReadUInt32BigEndian(bytes.AsSpan(9, 4));
// prf: 0 = HMACSHA1, 1 = HMACSHA256, 2 = HMACSHA512
Console.WriteLine($"format=0x{bytes[0]:X2} prf={prf} iterations={iterations} salt={saltBytes * 8} bit total={bytes.Length} bytes");
}
static void Measure(string label, PasswordHasher<AppUser> hasher, AppUser user, string password)
{
string hash = hasher.HashPassword(user, password);
Stopwatch watch = Stopwatch.StartNew();
hasher.VerifyHashedPassword(user, hash, password);
Console.WriteLine($"{label}: {watch.ElapsedMilliseconds} ms per verify");
}
record AppUser(string UserName);
AQAAAAIAAYagAAAAENxBa5Edz1gOe+czVLc5xnj3cw8VGOpreqnVGXaBxD0VtL8InpE1mqBugHNLeB4tCw==
AQAAAAIAAYagAAAAECFZdMynZ+Zrnb+Ca9aDGtmelcMC74H7Ay4IxOTlbxDKppgOQ8Ugi3LAncURZ1lpsg==
format=0x01 prf=2 iterations=100000 salt=128 bit total=61 bytes
SuccessRehashNeeded
Failed
format=0x01 prf=2 iterations=220000 salt=128 bit total=61 bytes
Success
100k: 28 ms per verify
220k: 61 ms per verify
The same password was hashed twice and produced two different values; the salt is generated fresh every time. The secret of the common prefix is in the third line of the output. The Base64-decoded value carries a header: the first byte 0x01 is the format version (IdentityV3), the next four bytes are the PRF (2, meaning HMAC-SHA512), the next four the iteration count (100,000), the next four the salt length (16 bytes, 128 bits). Then come the salt and the 32-byte derived key; 61 bytes in total. Because the algorithm and the cost are written inside the hash, old hashes keep verifying after you change the setting.
This default is the same from .NET 7 through .NET 10: PBKDF2-HMAC-SHA512 with 100,000 iterations. In .NET 6 and earlier it was PBKDF2-HMAC-SHA256 with 10,000 iterations. Here is the answer to the last opening question: OWASP's current recommendation for HMAC-SHA-512 is 220,000, so Identity's default falls short of it.
Upgrading to the OWASP Recommendation
The upgrade is a single line. In a real application you provide the options through DI:
builder.Services.Configure<PasswordHasherOptions>(options =>
{
options.IterationCount = 220_000;
});
In a web project you don't need to add the Microsoft.Extensions.Identity.Core package; it is already in the ASP.NET Core shared framework, and if you add it, restore says "this package is automatically available" with an NU1510 warning.
The most valuable line in the output is SuccessRehashNeeded. The hasher set to 220,000 verified the old hash produced with 100,000 but said "the password is correct, just renew this hash". When Identity's sign-in flow sees this result, it regenerates the hash with the new setting and saves it. So without asking anyone to change their password, the table updates itself as users sign in. The wrong password returned Failed, as expected.
Measure the price of the cost too. On my machine (Apple M5) one verification took 28 ms at 100,000 iterations and 61 ms at 220,000. You pay this price once per sign-in; an attacker making guesses pays it on every attempt. Still, remember that a sign-in endpoint can consume CPU; think about it together with rate limiting.
If you want Argon2id, .NET's own cryptography library has no Argon2; you need a third-party package and you implement the IPasswordHasher<TUser> interface yourself. Using PBKDF2 with the right iteration count is a reasonable balance for most projects.
The Data Protection API
In Bookstore we want to generate a link to share an order by email. The order number in the link must not be changeable, and the link must become invalid after 15 minutes. Instead of writing our own encryption code we use the Data Protection API that ASP.NET Core already uses; authentication cookies and antiforgery tokens are protected with this API too.
using System.Security.Cryptography;
using Microsoft.AspNetCore.DataProtection;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDataProtection()
.SetApplicationName("Bookstore")
.PersistKeysToFileSystem(new DirectoryInfo(Path.Combine(builder.Environment.ContentRootPath, "keys")));
var app = builder.Build();
app.MapPost("/orders/{id:int}/share-link", (int id, IDataProtectionProvider provider) =>
{
ITimeLimitedDataProtector protector = provider
.CreateProtector("Bookstore.OrderShare.v1")
.ToTimeLimitedDataProtector();
string token = protector.Protect(id.ToString(), TimeSpan.FromMinutes(15));
return Results.Ok(new { url = $"/shared/orders/{token}" });
});
app.MapGet("/shared/orders/{token}", (string token, IDataProtectionProvider provider) =>
{
ITimeLimitedDataProtector protector = provider
.CreateProtector("Bookstore.OrderShare.v1")
.ToTimeLimitedDataProtector();
try
{
string orderId = protector.Unprotect(token, out DateTimeOffset expiresAt);
return Results.Ok(new { orderId = int.Parse(orderId), expiresAt });
}
catch (CryptographicException)
{
return Results.Problem(statusCode: 400, title: "Invalid or expired link");
}
});
app.MapGet("/payment/status", (IConfiguration configuration) =>
{
IConfigurationRoot root = (IConfigurationRoot)configuration;
string? source = root.Providers
.Reverse()
.FirstOrDefault(p => p.TryGet("Payment:ApiKey", out _))
?.ToString();
return Results.Ok(new { configured = source is not null, source });
});
app.Run();
curl -i -X POST http://localhost:5126/orders/42/share-link
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:07:34 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"url":"/shared/orders/CfDJ8JGrubDBKElBugN-RlTw8ZmZD0D64N-eKGUvs_hsUOj2RM9Bn4PHNuiSD9GVwr5uQeCJW-F0TDVbKyCOcB_FB2ZEPPXDO2sS5Xsdb-e7Wv9B6ex9TxiwSoJNijbWTnZqcA"}
curl -i http://localhost:5126/shared/orders/CfDJ8JGrubDBKElBugN-RlTw8ZmZD0D64N-eKGUvs_hsUOj2RM9Bn4PHNuiSD9GVwr5uQeCJW-F0TDVbKyCOcB_FB2ZEPPXDO2sS5Xsdb-e7Wv9B6ex9TxiwSoJNijbWTnZqcA
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Date: Fri, 06 Nov 2026 19:07:41 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"orderId":42,"expiresAt":"2026-11-06T19:22:35.418763+00:00"}
I changed the second-to-last character of the token and sent the same request again:
HTTP/1.1 400 Bad Request
Content-Type: application/problem+json
Date: Fri, 06 Nov 2026 19:07:41 GMT
Server: Kestrel
Transfer-Encoding: chunked
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"Invalid or expired link","status":400}
The token is both encrypted and signed. A one-character change produced a CryptographicException; an expired token is rejected the same way. The purpose string you give to CreateProtector also provides isolation: data protected with "Bookstore.OrderShare.v1" cannot be opened with another purpose. The v1 at the end is there so that a future format change doesn't get mixed up with the old one.
When the application started for the first time, the log had this line:
warn: Microsoft.AspNetCore.DataProtection.KeyManagement.XmlKeyManager[35]
No XML encryptor configured. Key {b0b9ab91-28c1-4149-ba03-7e4654f0f199} may be persisted to storage in unencrypted form.
The XML file in the keys folder says the same (I trimmed the key value):
<key id="b0b9ab91-28c1-4149-ba03-7e4654f0f199" version="1">
<creationDate>2026-11-06T19:07:32.474999Z</creationDate>
<activationDate>2026-11-06T19:07:32.474999Z</activationDate>
<expirationDate>2027-02-04T19:07:32.474999Z</expirationDate>
<descriptor deserializerType="...">
<descriptor>
<encryption algorithm="AES_256_CBC" />
<validation algorithm="HMACSHA256" />
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
<!-- Warning: the key below is in an unencrypted form. -->
<value>...</value>
</masterKey>
</descriptor>
</descriptor>
</key>
We learn three things here. The key is valid for 90 days and uses AES-256-CBC with HMAC-SHA256; the framework does the rotation itself. On macOS the key file was written unencrypted by default; in Production you should protect the keys too, with a method such as ProtectKeysWithCertificate. And if your application runs on more than one server, all of them must share the same key store and the same SetApplicationName value; otherwise a cookie produced by one server won't open on another.
Where Should Secrets Live?
Bookstore has an API key for connecting to its payment provider. This key must not go into appsettings.json, the code or Git. As we saw in the Git & GitHub series, deleting a committed secret later is not enough, it stays in the history; the only real fix is to revoke the key and issue a new one. The /payment/status endpoint above doesn't show the value, it only tells you which configuration provider it came from. First, with nothing configured:
{"configured":false,"source":null}
On the development machine we use user-secrets:
dotnet user-secrets init
dotnet user-secrets set "Payment:ApiKey" "dev-only-pay-key-0000"
Set UserSecretsId to '35813713-c735-45f8-a2df-fc24db1de9c3' for MSBuild project '.../Bookstore.Api/Bookstore.Api.csproj'.
Successfully saved Payment:ApiKey to the secret store.
{"configured":true,"source":"JsonConfigurationProvider for 'secrets.json' (Optional)"}
The value lives in the user profile (on macOS and Linux ~/.microsoft/usersecrets/<id>/secrets.json), not in the project folder, so it cannot be committed by accident. But it isn't encrypted, and it is loaded only in the Development environment. When I started the same application in the Production environment, the answer was {"configured":false,"source":null} again; this is by design.
On a server the simplest way is an environment variable. For nested keys a double underscore replaces ::
export Payment__ApiKey=dev-only-from-env
{"configured":true,"source":"EnvironmentVariablesConfigurationProvider"}
The environment variable overrode user-secrets, because it is added later in the default order. One step further is a vault: a service such as Azure Key Vault, AWS Secrets Manager or HashiCorp Vault keeps keys centrally, logs access and makes rotation easier. If the application connects to the vault with its own identity (such as a managed identity), there isn't even a first secret left to store.
Common Pitfalls
The first is never looking at the iteration setting in an old Identity setup. In a project upgraded from .NET 6, old hashes may still have 10,000 iterations; even with the rehash flow working, users who don't sign in keep their old hash.
The second is not writing Data Protection keys to a persistent place. If the keys disappear with the container, every deploy signs users out and invalidates every link you generated.
The third is using Data Protection for long-term data storage. Keys rotate; if you protect data that must be readable for years with this API, you need to manage keys very carefully.
The fourth is logging a secret or showing it in an error message. The endpoint above deliberately returns the source, not the value. Writing a real key into appsettings.Development.json because "it's only development" is another route to the same leak.
Checklist
Store passwords only with a salted, slow algorithm; if you use Identity, raise PasswordHasherOptions.IterationCount to at least 220,000 and confirm the SuccessRehashNeeded flow works. Don't write your own password hash.
Use the Data Protection API for in-application protection, give each use its own purpose, keep the keys in a persistent, shared place and encrypt them in Production. Keep secrets in user-secrets during development, in environment variables or preferably a vault on the server; run a check that scans the repo for secrets, and revoke a leaked key immediately.
A Small Experiment
★☆☆ Set IterationCount to 600,000 in PasswordLab and measure the verification time. Would you accept that time for your sign-in page?
★★☆ Generate the share link with TimeSpan.FromSeconds(10), wait 10 seconds and see the same link return 400. Then try to open it with the "Bookstore.OrderShare.v2" purpose.
★★★ Stop the lab, move the keys folder somewhere else, restart the application and try the old link. Then put the folder back and try again; see with your own eyes why two servers need to share the same key store.
In the next post we move on to A05:2025 Injection: we will write the same search endpoint with ADO.NET, Dapper and EF Core and see the difference between string concatenation and parameters in running code, and then deal with input that goes to the command line.