Security in .NET applications
Do not trust any input, and for every piece of data ask "is this user allowed?". Hash passwords, encrypt sensitive data, and keep keys outside the code. Build several layers of defense so that breaking one layer does not break everything.
Author: bezzad
The problem: three pieces of bad news in one week
Our online shop has just started. In one week, three things happen:
- Ali sees other people’s orders. In the page address, he changes his own order number from 41 to 42. The server shows Sara’s order, with her address and phone number.
- The payment gateway key leaks. Someone opened the code repository to a contractor. The key was inside the settings file.
- The users database is copied. The passwords were stored as plain text. Now the attacker has everyone’s password. Many people use the same password for their email too.
None of these is a complex attack. All three come from simple and common mistakes. This lesson is about these mistakes.
The main idea: several layers of defense
No layer is perfect. So we do not depend on one layer. We call this approach “Defense in Depth”.
We have three basic rules:
- Do not trust input. Anything that comes from outside may be tampered with, even from our own mobile app.
- Give the least access. The application’s database user must not have permission to drop a table. If an attacker gets in, the damage is smaller.
- Closed by default. If you are not sure, do not allow it. A new endpoint must need sign-in automatically, not the other way around.
The OWASP Top 10 list
Every few years, the OWASP group publishes a list of the most common risks for web applications. In the 2025 version, the first item is still Broken Access Control. Wrong configuration and problems in the supply chain (the packages we use) are also near the top of the list. Injection and weak cryptography are still on the list too.
You do not need to memorize the list. In an interview, what matters is that you can see each risk in real code and say how to prevent it. Next, we look at the most important ones in our own shop.
Risk one: access control
User sign-in (Authentication) only says “this is Ali”. But it does not say “Ali is allowed to see order 42”. This second question is the job of Authorization. If we forget it, a user can reach other people’s data by changing one number. The name of this problem is IDOR.
app.MapGet("/orders/{id:guid}", async (
Guid id, ClaimsPrincipal user, ShopDb db, CancellationToken ct) =>
{
var customerId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
// The owner check is part of the query, not an afterthought.
var order = await db.Orders
.Where(o => o.Id == id && o.CustomerId == customerId)
.Select(o => new OrderDto(o.Id, o.Total, o.Status))
.FirstOrDefaultAsync(ct);
return order is null ? Results.NotFound() : Results.Ok(order);
})
.RequireAuthorization();
A few points in this code:
- The customer ID comes from the token, not from the input. If we read the customer ID from the address or the request body, Ali changes that too.
- The answer is 404, not 403. With 403, we tell the attacker “this order exists”. With 404, we tell them nothing.
- Only the needed fields come back. Do not return the whole Entity. It may contain a field like the purchase cost from the supplier.
Risk two: Injection
The product search page takes text from the user. If we glue this text directly into the SQL string, the user can write a SQL command instead of a product name.
// BAD: the user text becomes part of the SQL command.
var bad = await db.Products
.FromSqlRaw($"SELECT * FROM Products WHERE Name = '{name}'")
.ToListAsync(ct);
// GOOD: FromSql turns {name} into a SQL parameter.
var good = await db.Products
.FromSql($"SELECT * FROM Products WHERE Name = {name}")
.ToListAsync(ct);
// BEST: plain LINQ. EF Core always uses parameters.
var best = await db.Products
.Where(p => p.Name == name)
.ToListAsync(ct);
Why is the second version safe?
- The FromSql method takes the string in pieces. It takes the fixed text separately and the value of name separately.
- The value is sent as a parameter. The database sees it only as “data”, not as a “command”.
- The FromSqlRaw method does not do this. It sends the final string as it is. The difference between the two is only one word, so look carefully in Code Review.
Passwords: hash, not encryption
There are three similar things that people often mix up:
- Encoding. It only changes the form of the data. Anyone can reverse it. It is not security.
- Encryption. It can be reversed with a key. It fits data we need later, like the customer’s phone number.
- Hashing. It is one-way. It fits passwords. We never need to read the password. We only need to check if the entered password is correct.
But not every hash fits passwords. A fast hash like SHA-256 is bad for passwords. An attacker can try a very large number of passwords in one second. So we need two things:
- Salt. A random value for each user. Two users with the same password get different hashes. Ready-made hash tables also become useless.
- A slow algorithm. Like PBKDF2 or Argon2. Being slow does not matter for one user’s sign-in, but it makes guessing millions of passwords very expensive.
You do not need to write this yourself. The PasswordHasher class in ASP.NET Core Identity handles the salt and the slow algorithm by itself:
var hasher = new PasswordHasher<Customer>();
// On sign-up: store only the hash (salt is inside it).
customer.PasswordHash = hasher.HashPassword(customer, password);
// On sign-in: compare, never decrypt.
var result = hasher.VerifyHashedPassword(customer, customer.PasswordHash, password);
if (result == PasswordVerificationResult.Failed)
return Results.Unauthorized();
Managing Secrets
The payment gateway key, the database password and the token signing key are all Secrets. Their place is not inside the code repository. Even if you delete them from the file later, they stay in the git history.
The idea is this: the code always reads the setting with one fixed name. Only its source is different in each environment.
On the developer machine, use User Secrets. The value is stored outside the project folder:
dotnet user-secrets init
dotnet user-secrets set "Payment:ApiKey" "test-key-for-local"
In the real environment, use a Secret store like Azure Key Vault. The application signs in with its own identity (Managed Identity). So no password is needed to read the passwords:
var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsProduction())
{
// Packages: Azure.Extensions.AspNetCore.Configuration.Secrets, Azure.Identity
builder.Configuration.AddAzureKeyVault(
new Uri("https://shop-vault.vault.azure.net/"),
new DefaultAzureCredential());
}
// The same line works in every environment.
var apiKey = builder.Configuration["Payment:ApiKey"];
If a Secret leaks, removing it from the code is not enough. First change the key (Rotate). Then find out who used it and where.
The other layers at a glance
- Always HTTPS. With the UseHttpsRedirection method, send plain requests to HTTPS. With the UseHsts method, tell the browser to never come without HTTPS again.
- Keep packages up to date. The dotnet list package command with the vulnerable option shows packages that have known security problems. Run this in CI.
- Do not show the full error to the user. The developer error page shows file paths and code details. In the real environment, return only a general message and an error ID.
- Do not log passwords and Secrets. Logs reach more people than the database. Card numbers, passwords and tokens must not be in logs.
- Limit sign-in. For the sign-in page, set a limit on the number of requests (Rate Limiting). Otherwise an attacker tries thousands of passwords one after another.
- Record security events. Log failed sign-ins, password changes and denied access, and have alerts for them.
Common mistakes
| Mistake | Result | The right way |
|---|---|---|
| Only a sign-in check, no owner check | A user sees other people’s data by changing a number. | Check the owner inside the query itself. |
| Reading the user ID from the request input | A user pretends to be someone else. | Read the ID from the token. |
| Gluing user text into a SQL string | The attacker runs their own command. | Parameters, or plain LINQ. |
| Storing passwords with a fast hash or with encryption | When the database leaks, the passwords leak too. | The PasswordHasher class, or a slow algorithm with salt. |
| A key in a settings file inside git | Anyone who has the repository has the key. | The User Secrets tool and Key Vault. |
| Removing a leaked Secret from the code | The key is still in the git history and in the attacker’s hands. | First change the key. |
| Sending the whole Entity in the API response | Internal and sensitive fields go out. | A DTO with only the needed fields. |
Where does security start?
Right
- From design day. For every new endpoint ask: “Who is allowed?”
- With safe defaults: all endpoints need sign-in.
- With automatic checks of packages and Secrets in CI.
- With Code Review that also looks at security.
Wrong
- At the end of the project, with one penetration test.
- By trusting that “the frontend does not show this button”.
- With the thought “our system is small, nobody will attack”. Bots try all sites automatically.
Summary in six lines
- No layer is perfect. Build several layers of defense.
- Sign-in is not enough. For every piece of data, ask if this user is allowed.
- Never glue user text into a command. Use parameters.
- Hash passwords with a slow algorithm and salt. Encrypt the data you need.
- No Secret should be in the code repository. If one leaks, first change it.
- Keep packages up to date, and do not show errors and Secrets to the outside.