A login screen can work perfectly while the application behind it leaks another customer's invoices. A database can use encrypted disks while storing passwords that attackers can crack quickly. These failures happen because security controls solve different problems, and one control rarely substitutes for another. Let's follow a small invoice application through the decisions that protect its data, credentials, and users.

The core idea: check every boundary

Treat a web application like a hotel. Reception confirms who you are, your room key limits where you can go, and the safe protects valuables if someone enters the building. In software, these correspond to authentication, authorization, and protection of stored data.

Authentication establishes identity: Alice successfully signs in. Authorization determines permission: Alice may read her invoice, but not Bob's. Input validation checks whether submitted data fits the expected format and business rules.

Our running example, LedgerLite, stores invoice 42 for Alice and invoice 43 for Bob. It also holds password records and a credential for a payment provider. Each request crosses boundaries where the application must decide what to trust:

Browser -> authenticated application -> scoped database query
                     |
                     +-> payment provider
                     +-> restricted secret store

The browser controls its requests. A hidden button or an unguessable-looking invoice identifier cannot establish permission.

The OWASP Top 10 as practical questions

The Open Worldwide Application Security Project, OWASP, publishes guidance on application security. Its Top 10:2025 identifies broad risk categories; it is an awareness document, not a complete security specification.

Here is what all ten categories mean for LedgerLite:

2025 category Concrete failure Practical response A01 Broken Access Control Alice requests Bob's invoice. Check ownership on every request. A02 Security Misconfiguration Production exposes debugging pages. Review deployment settings and disable debugging. A03 Software Supply Chain Failures A compromised dependency enters a release. Review dependencies, lock versions, and protect builds. A04 Cryptographic Failures Passwords use a fast general-purpose hash. Use a dedicated password-hashing algorithm. A05 Injection An invoice filter changes a database command. Parameterize queries and encode displayed text. A06 Insecure Design Customers can approve their own refunds. Define and enforce business permissions before coding. A07 Authentication Failures Unlimited guesses compromise an account. Throttle sign-in attempts and strengthen authentication. A08 Software or Data Integrity Failures A forged payment notification marks an invoice paid. Verify the provider's signature before accepting it. A09 Security Logging and Alerting Failures Repeated account attacks generate no alert. Record relevant events and monitor them. A10 Mishandling of Exceptional Conditions An authorization timeout grants access. Reject access when permission cannot be established.

A signature lets the receiver check that a message came from a party holding the relevant signing credential and was not altered. For payment notifications, also validate timestamps or event identifiers as the provider specifies: a valid old message can still be replayed.

How a secure invoice request works

  1. Establish identity on the server

After login, LedgerLite creates a session, a record connecting subsequent requests to an authenticated user. A browser cookie stores a value and automatically sends it with matching requests. For session cookies, set HttpOnly to prevent JavaScript from reading them, Secure to restrict transmission to HTTPS, and an appropriate SameSite policy to limit cross-site sending.

HTTPS is encrypted HTTP, the web's Hypertext Transfer Protocol. Certificates let browsers verify the server's identity. Cookie settings do not make the underlying application secure by themselves. Rotate session identifiers at login, expire sessions, and invalidate them when necessary.

An API, an application programming interface, may instead accept a signed access token: a credential carrying verifiable identity and permission information. Validate its signature, expiration, intended recipient, and issuing authority using maintained authentication libraries. Never treat an unsigned client field such as userId: "alice" as proof of identity.

  1. Validate input, then query within permission

SQL, Structured Query Language, expresses database operations. SQL injection occurs when attacker-controlled input becomes part of an SQL command instead of remaining a value.

Node.js runs JavaScript on the server. This example assumes Express, a web framework; pg, a PostgreSQL database client; and requireLogin, middleware that validates a session and sets req.user.id. Middleware runs before the request handler. pool manages reusable database connections.

app.get("/invoices/:id", requireLogin, async (req, res, next) => {
  try {
    const id = Number(req.params.id);
    if (!Number.isSafeInteger(id) || id <= 0) {
      return res.status(400).json({ error: "Invalid invoice ID" });
    }

    const { rows } = await pool.query(
      "SELECT id, total_cents FROM invoices WHERE id = $1 AND owner_id = $2",
      [id, req.user.id]
    );
    if (!rows[0]) {
      return res.status(404).json({ error: "Not found" });
    }
    res.json(rows[0]);
  } catch (error) {
    next(error);
  }
});

The numbered placeholders keep values separate from the command. The ownership condition independently enforces authorization. Alice requesting invoice 43 receives HTTP 404, the missing-resource status, without learning whether Bob's invoice exists.

In ASP.NET Core, the web framework for the .NET development platform, the equivalent can use Entity Framework Core, its database-access library. This fragment assumes configured authentication, an invoice database context named LedgerDb, and the indicated namespaces:

using System.Security.Claims;
using Microsoft.EntityFrameworkCore;

app.MapGet("/invoices/{id:int}", async (
    int id, ClaimsPrincipal user, LedgerDb db) =>
{
    if (id <= 0) return Results.BadRequest();
    var owner = user.FindFirst(ClaimTypes.NameIdentifier)?.Value;
    if (owner is null) return Results.Unauthorized();

    var invoice = await db.Invoices
        .Where(i => i.Id == id && i.OwnerId == owner)
        .Select(i => new { i.Id, i.TotalCents })
        .SingleOrDefaultAsync();

    return invoice is null ? Results.NotFound() : Results.Ok(invoice);
}).RequireAuthorization();

ClaimsPrincipal represents the authenticated identity and its claims, or identity attributes. The query translates values into database parameters. Avoid inserting user strings into raw SQL; parameters also cannot choose column names, so map requested sort options to a fixed allowlist.

  1. Protect browser output and state changes

Cross-site scripting, or XSS, occurs when untrusted content runs as script in a user's browser. An invoice note containing malicious HTML, the markup language structuring web pages, must appear as text. Use textContent for plain text; ASP.NET Razor templates encode ordinary text expressions automatically. Avoid innerHTML and Html.Raw for untrusted notes.

Encoding must match the destination: making text safe in HTML does not make it safe inside JavaScript or a URL. If rich formatting is required, use a maintained sanitizer that removes unsafe markup.

Cross-site request forgery, or CSRF, tricks a browser into submitting an unwanted authenticated request. LedgerLite's cookie-authenticated “pay invoice” action needs the framework's antiforgery protection, typically a token the attacker cannot obtain. Use state-changing methods such as POST and treat SameSite as additional protection. CORS, Cross-Origin Resource Sharing, controls which browser origins—combinations of scheme (such as HTTPS), hostname, and port—may read responses; it does not authorize callers.

If LedgerLite fetches a customer-supplied receipt URL, a network resource address, server-side request forgery, or SSRF, could make it contact internal services. Restrict permitted destinations, redirects, resolved network addresses, and outbound network access. A URL-format check alone is insufficient.

  1. Handle failure deliberately

If permission lookup fails, reject the operation. If the payment provider times out, do not mark the invoice paid: the outcome may be unknown. Provider-supported idempotency keys, identifiers that prevent retries from duplicating an operation, help reconcile payment retries.

Centralize error handling. Return a generic server-error response while logging a request identifier and sanitized diagnostic details. Never log passwords, session cookies, payment credentials, or whole request bodies indiscriminately.

Store passwords so a database leak is harder to exploit

Password hashing derives a verification value without storing the original password. Unlike reversible encryption, it provides no decryption operation. Attackers can still guess passwords and compare results, so fast hashes such as SHA-256 alone are unsuitable.

A password-hashing algorithm deliberately makes guesses expensive. A salt is a random value generated separately for each password record; it prevents identical passwords from producing identical stored hashes and defeats reusable precomputed guesses. Salts are public and belong with the hash.

For new Node.js password storage, Argon2id is a useful default when supported. It consumes configurable memory as well as computation. OWASP's password guidance recommends at least 19 MiB of memory, two passes, and parallelism one; benchmark higher settings against your capacity. A MiB is 1,048,576 bytes, and parallelism controls concurrent processing within one hash.

Hands-on: create and verify Alice's password record

Use a supported Node.js release and npm, its package manager. The argon2 package includes compiled code; installation may require build tools when your platform lacks a prebuilt binary. Its memory setting uses KiB, units of 1,024 bytes.

mkdir ledgerlite-lab
cd ledgerlite-lab
npm init -y
npm install argon2

Save this as password-demo.cjs:

const argon2 = require("argon2");

async function main() {
  const password = "demo-only-long-passphrase";
  const options = {
    type: argon2.argon2id,
    memoryCost: 19456, // KiB: 19 MiB
    timeCost: 2,
    parallelism: 1
  };

  const hash = await argon2.hash(password, options);
  const secondHash = await argon2.hash(password, options);

  console.log("Argon2id:", hash.startsWith("$argon2id$"));
  console.log("Different salts:", hash !== secondHash);
  console.log("Correct password:", await argon2.verify(hash, password));
  console.log("Wrong password:", await argon2.verify(hash, "wrong"));
}

main().catch(() => {
  console.error("Password demo failed");
  process.exitCode = 1;
});
node password-demo.cjs

Expected output:

Argon2id: true
Different salts: true
Correct password: true
Wrong password: false

Store the complete encoded hash in Alice's user row. It contains the algorithm, parameters, salt, and derived value; the library generates the salt. On login, verify the submitted password against that record. After successful verification, upgrade outdated parameters and persist a replacement hash.

Throttle guesses, limit simultaneous hashing operations, and bound request sizes: expensive hashes can also exhaust your server. Support long passphrases, reject known compromised passwords, and add multi-factor authentication, which requires another factor such as a security key. Hashing does not prevent phishing—tricking users into revealing credentials—or password reuse.

The .NET approach

Prefer ASP.NET Core Identity's account-management and sign-in flows over assembling your own. Its built-in password hasher stores salts and versioned parameters and supports upgrading hashes.

In .NET 10, Identity uses PBKDF2 (Password-Based Key Derivation Function 2), repeating HMAC (hash-based message authentication code) operations based on SHA-512. Its default iteration count is 100,000; OWASP recommends at least 220,000 for this variant. In an application already using Identity, configure and benchmark the count before building the application:

using Microsoft.AspNetCore.Identity;

builder.Services.Configure<PasswordHasherOptions>(options =>
    options.IterationCount = 220_000);

Defaults can change with framework versions. If calling PasswordHasher directly, handle SuccessRehashNeeded by storing a new hash after successful verification. The complete Identity flow handles more account concerns than this configuration fragment.

Keep application secrets outside the code

LedgerLite must recover its payment API key to call the provider. That secret, a credential granting access, needs different treatment from a user's password hash. Do not hash it: the provider needs the original credential.

For local Node development, an excluded .env file can hold disposable development credentials. For .NET, run these commands inside the application project:

dotnet user-secrets init
dotnet user-secrets set "Payments:ApiKey" "development-only-placeholder"

Neither .env files nor .NET user-secrets provide encrypted storage. Restrict local access, use separate development credentials, and avoid these stores for production.

In production, retrieve credentials from a secret manager, a service controlling credential storage and access, using workload identity, an identity assigned to the running application. Grant LedgerLite access only to its required secrets, audit access, and plan rotation. Environment-variable injection is convenient but may expose values through debugging tools or crash dumps.

Validate required configuration at startup without printing values. Keep secrets out of browser bundles, repository history, build output, and logs. If a key leaks, revoke or rotate it immediately; deleting the file does not revoke copies.

Tradeoffs and alternatives

Choice Use it when Limits or alternative Argon2id Creating password storage with library support Memory cost requires capacity planning. scrypt Argon2id is unavailable Another memory-intensive option; configure library memory limits. PBKDF2 Using Identity or meeting cryptographic compliance requirements Benchmark the specific hash variant and iteration count. bcrypt Maintaining a legacy password store Avoid for new designs; typical implementations limit input to 72 bytes. Managed identity provider (hosted login service) Your team cannot safely maintain login and recovery Adds vendor dependence; invoice authorization remains your responsibility. Secret manager Operating production credentials Adds availability and rotation concerns; usually unnecessary for a disposable local experiment.

A pepper, a shared secret stored separately from the password database, can add protection against database-only theft. It also complicates recovery and rotation, potentially requiring password resets. Introduce it only with a workable operational plan.

Dependency lock files record resolved package versions. They improve repeatability, but do not prove packages are safe. Review changes, scan known vulnerabilities, protect build credentials, and deploy maintained releases.

Common mistakes and their fixes

  1. Checking login but ignoring ownership. Apply resource permissions inside every read and write operation, including exports and background jobs.
  2. Treating validation as injection prevention. Validate business rules, parameterize database values, and encode output for its destination.
  3. Encrypting passwords or using SHA-256 alone. Use a password hasher with generated salts and measured work settings.
  4. Calling a committed key “fixed” after deletion. Revoke it, replace it, and investigate where copies or unauthorized use occurred.
  5. Returning debug errors in production. Send generic errors, preserve sanitized diagnostics, and alert on meaningful failure patterns.

Quick recap

· Authentication establishes identity; authorization checks each requested resource. · Parameters protect database commands; output encoding protects browser contexts. · Password hashes need salts and deliberately expensive algorithms. · Recoverable application secrets need restricted storage and rotation. · Secure behavior includes deployment, monitoring, and deliberate failure handling.

Test yourself

  1. Alice is authenticated. Why can invoice 43 still be forbidden? Answer: Authentication proves identity, not ownership. The application must check whether Alice may access that invoice.
  2. Why may the salt be stored beside the password hash? Answer: Its purpose is uniqueness, not secrecy. It prevents shared passwords and precomputed guesses from sharing one useful hash result.
  3. Does deleting a leaked payment key from Git make it safe? Answer: No. Copies may remain usable. Revoke or rotate the credential and investigate its exposure.

Where to go next

· Threat modeling: map LedgerLite's trust boundaries and abuse cases before adding features. · Passkeys and account recovery: learn authentication with cryptographic credentials and how recovery can undermine it. · Security verification: use the OWASP Application Security Verification Standard to turn these ideas into reviewable requirements.