Levelwise
English
Architecture and system design

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.

Not reviewedWritten with AI helpReading time: 16 minOnline shop exampleC# and .NET 10 code

Author: bezzad

The problem: three pieces of bad news in one week

Our online shop has just started. In one week, three things happen:

  1. 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.
  2. The payment gateway key leaks. Someone opened the code repository to a contractor. The key was inside the settings file.
  3. 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”.

Each layer stops one kind of attackUseror attackerEncryptedHTTPSWho?AuthenticationAllowed?AuthorizationValid input?ValidationLeast accessLeast privilegeDatabaseEavesdroppingNo-password loginOthers' ordersInjectionLess damageIf one layer breaks, the next layer is still in place
A request passes through several doors. Each door asks a different question.

We have three basic rules:

  1. Do not trust input. Anything that comes from outside may be tampered with, even from our own mobile app.
  2. 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.
  3. 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.

AliSigned inGET /orders/42Order 42 belongs to Sara, not Ali.No owner checkWHERE Id = 42Sara's addressleakedWith owner checkWHERE Id = 42 AND Owner = AliNot found404Sign-in is not enough. We must ask if this data belongs to this user
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:

  1. 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.
  2. The answer is 404, not 403. With 403, we tell the attacker “this order exists”. With 404, we tell them nothing.
  3. Only the needed fields come back. Do not return the whole Entity. It may contain a field like the purchase cost from the supplier.
A random ID is not enough. A Guid ID makes guessing hard, but it does not replace the owner check. IDs spread in logs, emails and links.

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?

  1. The FromSql method takes the string in pieces. It takes the fixed text separately and the value of name separately.
  2. The value is sent as a parameter. The database sees it only as “data”, not as a “command”.
  3. 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.
It is not only SQL. The same idea is true for operating system commands, LDAP queries and HTML. In HTML, if user text goes into the page without Encode, the attacker’s script runs in other people’s browsers (XSS). Razor pages Encode text by default.

Passwords: hash, not encryption

There are three similar things that people often mix up:

InputMethodReversible?Ali123EncodingBase64Yes, for anyoneNo security at all09121234567EncryptionAES + keyYes, only with the keyFor data needed laterP@ssw0rdSlow hash with saltPBKDF2 / Argon2No, it is one-wayWe only compare
For passwords, a slow hash with salt. For data we must read later, encryption.
  1. Encoding. It only changes the form of the data. Anyone can reverse it. It is not security.
  2. Encryption. It can be reversed with a key. It fits data we need later, like the customer’s phone number.
  3. 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:

  1. Salt. A random value for each user. Two users with the same password get different hashes. Ready-made hash tables also become useless.
  2. 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();
Do not build your own encryption algorithm. Ready-made libraries have survived years of attacks. To encrypt data in ASP.NET Core, use Data Protection. This library creates and rotates the keys by itself.

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.

Badappsettings.jsonPayment key inside the filegit pushAnyone with access to the repohas the key foreverGoodDeveloper machineUser SecretsReal environmentKey VaultThe app reads one fixed namePayment:ApiKeyCode stays the same, only the settings source changes

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"];
A small point: In Key Vault, a Secret name cannot have a colon. So we write it as Payment–ApiKey. The library turns the two dashes into a colon.

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

  1. Always HTTPS. With the UseHttpsRedirection method, send plain requests to HTTPS. With the UseHsts method, tell the browser to never come without HTTPS again.
  2. 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.
  3. 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.
  4. Do not log passwords and Secrets. Logs reach more people than the database. Card numbers, passwords and tokens must not be in logs.
  5. 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.
  6. 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

  1. No layer is perfect. Build several layers of defense.
  2. Sign-in is not enough. For every piece of data, ask if this user is allowed.
  3. Never glue user text into a command. Use parameters.
  4. Hash passwords with a slow algorithm and salt. Encrypt the data you need.
  5. No Secret should be in the code repository. If one leaks, first change it.
  6. Keep packages up to date, and do not show errors and Secrets to the outside.