This is the security checklist I go through on the stacks I’ve used most as a developer: ASP.NET Core and .NET, REST APIs, React and npm, and SQL and NoSQL databases. Each section is short enough to skim before a release or a code review. Where the right setting isn’t obvious, there’s a small code sample. The list isn’t exhaustive, and the OWASP cheat sheets and vendor docs linked at the end go deeper on each topic.
ASP.NET Core security best practices
These points apply to MVC, Razor Pages and Web API projects alike. The API-specific extras are in the next section.
HTTPS, HSTS and security headers
-
- Serve everything over HTTPS, with TLS 1.2 as the minimum and TLS 1.3 where your host supports it. UseHttpsRedirection sends HTTP requests to HTTPS, and UseHsts tells browsers to stop trying HTTP at all. The default HSTS max-age is only 30 days. Raise it to a year once you know every subdomain works over HTTPS.
- Send X-Content-Type-Options: nosniff, a Referrer-Policy and a Content Security Policy. CSP is what limits the damage when an XSS bug slips through. Its frame-ancestors directive handles clickjacking and replaces the older X-Frame-Options header, though sending both does no harm.
- Stop advertising your stack. Kestrel adds Server: Kestrel unless you turn it off. IIS adds Server and X-Powered-By, which you remove in web.config with removeServerHeader under request filtering and a <remove> entry in custom headers.
var builder = WebApplication.CreateBuilder(args);
builder.WebHost.ConfigureKestrel(options => options.AddServerHeader = false);
builder.Services.AddHsts(options => options.MaxAge = TimeSpan.FromDays(365));
var app = builder.Build();
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/error");
app.UseHsts();
}
app.UseHttpsRedirection();
app.Use(async (context, next) =>
{
var headers = context.Response.Headers;
headers["X-Content-Type-Options"] = "nosniff";
headers["Referrer-Policy"] = "strict-origin-when-cross-origin";
headers["Content-Security-Policy"] =
"default-src 'self'; object-src 'none'; frame-ancestors 'none'";
await next();
});
A strict CSP will break any inline scripts. Move them into files, or generate a nonce per request and put it on both the script tag and the policy.
Authentication, passwords and lockout
- Use ASP.NET Core Identity or an OpenID Connect provider such as Microsoft Entra ID, Auth0 or Keycloak. Don’t write your own login. Identity hashes passwords with PBKDF2 and includes two-factor authentication and account lockout.
- Lockout only applies if you ask for it: pass lockoutOnFailure: true to PasswordSignInAsync. The default is false.
- You’ll still see advice to require symbols and force regular password changes. NIST no longer recommends either. The 2025 revision of SP 800-63B asks for at least 15 characters when the password is the only factor (8 when it’s combined with MFA), no composition rules, no scheduled expiry, and a check of new passwords against lists of breached passwords. Identity’s defaults (6 characters plus a digit, an uppercase letter and a symbol) don’t match that, so set PasswordOptions yourself.
- Throttle the login, registration and password-reset endpoints. The rate limiting section below shows how.
Authorization
Require an authenticated user everywhere by default with a fallback policy, then open the public pages with [AllowAnonymous]. If someone forgets an attribute on a new controller, it fails closed.
builder.Services.AddAuthorization(options =>
{
options.FallbackPolicy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.Build();
});
Use roles for coarse checks and policies built on claims for everything finer. Policies are easier to test, and you don’t end up with role names spread across the codebase as strings.
Input validation and overposting
- Validate on the server every time. Client-side validation exists for the user’s convenience, and anyone can bypass it. Data annotations such as [Required], [StringLength] and [Range] cover most cases, and with [ApiController] an invalid model returns a 400 automatically.
- Bind requests to view models or DTOs that contain only the fields a user is allowed to set. If you bind straight to an Entity Framework entity, an attacker can post IsAdmin=true and the model binder will set it. This attack is called overposting, or mass assignment.
- For file uploads, enforce a size limit and ignore the client’s file name and content type. Generate your own file name, store files outside wwwroot, and scan them if your platform allows it.
CSRF protection
Razor Pages validate antiforgery tokens on their own. In MVC, register AutoValidateAntiforgeryTokenAttribute as a global filter instead of remembering [ValidateAntiForgeryToken] on each POST action. Minimal APIs in .NET 8 and later validate form posts once you call AddAntiforgery and UseAntiforgery. SameSite cookies reduce the risk too, but they don’t replace the tokens.
CORS
CORS is a browser mechanism that relaxes the same-origin policy for the origins you list. curl, Postman and server-side code ignore it completely, so it doesn’t keep anyone away from your API. Its job is to say which front ends a browser may call you from. Name exact origins, methods and headers, and keep * out of production. ASP.NET Core won’t let you combine AllowAnyOrigin with AllowCredentials, and you shouldn’t look for a way around that.
Cookies and sessions
- Authentication cookies should be HttpOnly (Identity’s are by default), have SecurePolicy set to Always, and use SameSite=Lax or Strict. A CookiePolicyOptions setup applies the same rules to every cookie the app writes.
- Session IDs from the session middleware are already random. Set an IdleTimeout that suits the app (the default is 20 minutes), and don’t keep secrets or authorization decisions in session state.
Secrets and the Data Protection API
- Keep connection strings and API keys out of source code and appsettings.json. In development, the Secret Manager (dotnet user-secrets) stores them as plain JSON in your user profile. That’s fine for a dev machine and nothing else. In production, use Azure Key Vault, AWS Secrets Manager or your platform’s secret store. Use a managed identity so you don’t have to store a credential for the vault itself.
- The Data Protection API encrypts authentication cookies, antiforgery tokens and TempData, so you need to know where its keys live. By default they sit on the local disk or in the registry. In a container that’s restarted, the keys vanish and every user gets signed out. Behind a load balancer, each instance makes its own keys unless you share them. Persist the keys to a shared store, protect them at rest, and set an application name. One way to do that is PersistKeysToAzureBlobStorage with ProtectKeysWithAzureKeyVault, plus SetApplicationName.
Error handling and logging
- Use UseExceptionHandler in production and keep the developer exception page in development, which is how the templates are set up. Error responses (ProblemDetails for APIs) shouldn’t include stack traces, SQL errors or internal paths.
- Log with ILogger, Serilog or NLog, but never log passwords, tokens, connection strings or whole request bodies. Destructuring an entire object into a log message is how most of these leaks happen. Since .NET 8, Microsoft.Extensions.Compliance.Redaction can mask classified fields automatically.
- Log security events on purpose: failed logins, lockouts, authorization failures and password resets. If nobody alerts on them, logging them doesn’t help much.
Rate limiting
Since .NET 7, rate limiting is built in (Microsoft.AspNetCore.RateLimiting), so you no longer need a package like AspNetCoreRateLimit. Partition the limit by client. A plain fixed-window policy is shared by everyone, and five attempts per minute across all users would lock out the whole site.
builder.Services.AddRateLimiter(options =>
{
options.RejectionStatusCode = StatusCodes.Status429TooManyRequests;
options.AddPolicy("login", context =>
RateLimitPartition.GetFixedWindowLimiter(
context.Connection.RemoteIpAddress?.ToString() ?? "unknown",
_ => new FixedWindowRateLimiterOptions
{
PermitLimit = 5,
Window = TimeSpan.FromMinutes(1)
}));
});
app.UseRateLimiter();
app.MapPost("/account/login", LoginHandler).RequireRateLimiting("login");
Behind a proxy or load balancer, configure the forwarded headers middleware, or every request will appear to come from the proxy’s IP. Application-level limits help against brute force. A real DDoS attack has to be stopped earlier, at a CDN or WAF such as Azure Front Door or Cloudflare.
Dependencies and security testing
- .NET servicing releases ship on Patch Tuesday every month, so plan a monthly update. dotnet list package –vulnerable –include-transitive lists known-vulnerable packages, and NuGet audit has flagged them during restore since the .NET 8 SDK. Dependabot or Renovate can open the update pull requests for you.
- Scan the running app with ZAP (formerly OWASP ZAP) or Burp Suite, ideally against a staging environment from your CI pipeline. Static analysis such as GitHub CodeQL catches a different set of problems in the source.
REST API security best practices
Everything in the ASP.NET Core section applies to APIs as well. These are the additional checks.
Tokens and authentication
Issue tokens from a real identity provider using OAuth 2.0 and OpenID Connect, and validate the JWT on every request. When you set an Authority, ASP.NET Core loads the issuer and signing keys from the provider’s metadata. It also checks token lifetime by default, with five minutes of clock skew.
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://login.example.com/";
options.Audience = "orders-api";
});
- Keep access tokens short-lived (minutes, not days) and use refresh tokens for longer sessions.
- A JWT payload is Base64-encoded, not encrypted, so never put personal or secret data in it.
- Keep passwords, API keys and tokens out of URLs. Query strings end up in server logs, proxy logs, browser history and Referer headers.
Object-level authorization
Check that the signed-in user is allowed to see the specific record they ask for. If GET /orders/1043 returns another customer’s order just because the ID is valid, you have the flaw OWASP calls broken object level authorization (it used to be known as insecure direct object reference). It is number one on the OWASP API Security Top 10. In ASP.NET Core, resource-based authorization handles it: load the order, then call IAuthorizationService.AuthorizeAsync(User, order, “OrderOwner”) before returning it.
Data exposure, request limits and CSRF
- Return DTOs, not entities, and leave out the fields the client doesn’t need. If a field is only hidden by the front end, the client still receives it.
- Cap request sizes and page sizes. Kestrel’s default maximum body size is about 28.6 MB (30,000,000 bytes), which is far more than most JSON endpoints should accept.
- Return 429 with a Retry-After header when a client goes over its limit, and partition the limits by user or API key as well as by IP.
- If the API authenticates with bearer tokens in the Authorization header, CSRF doesn’t apply, because browsers never attach that header on their own. If it uses cookies (a same-site SPA or a backend-for-frontend), CSRF does apply: send an antiforgery token in a request header and validate it on every state-changing request.
- Swagger UI is HTML served by your API. Disable it in production or put it behind authentication.
.NET and C# security best practices
SQL injection in EF Core and ADO.NET
EF Core parameterizes LINQ queries. The raw SQL methods are where people get caught, because two of them look almost the same:
// Injectable: the value becomes part of the SQL text
var bad = db.Users.FromSqlRaw($"SELECT * FROM Users WHERE Email = '{email}'").ToList();
// Safe: EF Core sends the interpolated value as a parameter
var safe = db.Users.FromSql($"SELECT * FROM Users WHERE Email = {email}").ToList();
With plain ADO.NET, always use SqlParameter and never concatenate user input into a command string.
XSS in Razor
Razor HTML-encodes everything it outputs. Html.Raw() and HtmlString skip that encoding, so use them only for content you produced yourself or sanitized. When you write values into JavaScript or URLs, use JavaScriptEncoder and UrlEncoder, because HTML encoding isn’t enough in those contexts.
Deserialization and XML
- BinaryFormatter was removed in .NET 9 because it can’t be made safe. Avoid SoapFormatter, NetDataContractSerializer and LosFormatter for the same reason, and never deserialize untrusted JSON with Json.NET when TypeNameHandling is set to anything other than None. System.Text.Json is safe by default because it only creates the types you ask for.
- An XML External Entity (XXE) attack uses a DTD in the payload to read local files or call internal URLs. XmlReader prohibits DTD processing by default in .NET (Core) and in .NET Framework 4.5.2 and later. Keep it that way: don’t set DtdProcessing.Parse, and leave XmlResolver null. Older Framework code that uses XmlDocument or XmlTextReader needs these settings applied explicitly.
Cryptography and password storage
- For data you need to decrypt later, use AES-GCM (the AesGcm class) with a 256-bit key. Don’t use MD5 or SHA-1 for anything security-related.
- Store passwords with a password hashing algorithm: Argon2id, bcrypt or PBKDF2 (Identity’s PasswordHasher uses PBKDF2). Never store them with a single SHA-256 pass.
- Generate tokens and keys with RandomNumberGenerator, not System.Random.
- Store an algorithm or version marker next to anything you encrypt or hash, so you can switch algorithms later without breaking existing data.
Memory, disposal and the type system
- Managed code is bounds-checked, which is what prevents buffer overflows. unsafe blocks, pointers, stackalloc and P/Invoke bypass that, so keep them in small, reviewed parts of the code.
- Dispose of anything that holds a connection, a file handle or key material with a using statement. To wipe sensitive bytes, call CryptographicOperations.ZeroMemory. Microsoft no longer recommends SecureString for new code.
- Use the type system to reject bad data early. Turn on nullable reference types, use enums and specific types where you’d otherwise pass a string around, and enable the .NET security analyzers. CA2100 flags SQL built from strings, and CA5350 and CA5351 flag weak cryptography.
Least privilege and updates
- Don’t run the app as an administrator or root. Since .NET 8, the official container images include a non-root user, app; switch to it with USER $APP_UID in your Dockerfile.
- Windows Update patches .NET Framework. Modern .NET is different. You install the patched runtime yourself (Microsoft Update can deliver it on Windows Server if you opt in), and self-contained apps and container images have to be rebuilt and redeployed before the fix reaches them.
Legacy .NET Framework apps (Web Forms and MVC 5)
- In web.config, set <compilation debug=”false”> and <trace enabled=”false”>, set customErrors to On or RemoteOnly, and add <httpCookies httpOnlyCookies=”true” requireSSL=”true”>. Set requireSSL on forms authentication as well. Remove configuration sections you don’t use.
- Encrypt sensitive sections such as connection strings with aspnet_regiis -pef “connectionStrings” “C:\path\to\site”.
- In Web Forms, set ViewStateUserKey to the session ID in Page_Init (the master page is a good place) to protect against CSRF. In MVC 5, add @Html.AntiForgeryToken() to forms and [ValidateAntiForgeryToken] to every POST and PUT action.
- The old Membership and Role providers (Roles.IsUserInRole and friends) still work, but check how they store passwords, because older configurations use weak or even reversible formats. Moving to ASP.NET Identity fixes this.
- Code Access Security isn’t supported in .NET Core or .NET 5 and later, and Microsoft doesn’t treat it as a security boundary even in .NET Framework. Don’t rely on it.
- For secrets stored on a Windows machine, ProtectedData (the Windows DPAPI) ties encryption to the user or the machine. For cross-platform code, use ASP.NET Core Data Protection instead.
- For ClickOnce, sign the application and deployment manifests with a trusted code-signing certificate and publish over HTTPS. Most ClickOnce apps run with full trust, so the signature is the main thing protecting your users from a tampered update.
React security best practices
Preventing XSS in React
JSX escapes every value you render with curly braces, in both text and attributes, and that covers most XSS. The gaps are dangerouslySetInnerHTML, URLs in href and src, code that writes to the DOM directly, and third-party libraries that do any of those.
- If you have to render HTML with dangerouslySetInnerHTML, sanitize it first with DOMPurify: DOMPurify.sanitize(html).
- React doesn’t stop a javascript: URL in a link. Parse user-supplied URLs and allow only http and https:
function safeHref(input) {
try {
const url = new URL(input, window.location.origin);
return ['http:', 'https:'].includes(url.protocol) ? url.href : '#';
} catch {
return '#';
}
}
- Don’t write HTML through refs (ref.current.innerHTML). findDOMNode was removed in React 19, which is one more reason to stop using it.
- Never pass strings to eval(), new Function() or setTimeout.
- A single-page app built with Vite usually has no inline scripts, so a CSP of script-src ‘self’ is achievable. If your framework injects inline scripts for server rendering, use a per-request nonce. Next.js supports nonces through middleware.
- Turn on the lint rules that catch these mistakes: react/no-danger, react/jsx-no-script-url and react/jsx-no-target-blank from eslint-plugin-react, plus eslint-plugin-security. Run them on commit with husky and lint-staged, and again in CI.
Server-side rendering
renderToString() and renderToStaticMarkup() escape content as long as it goes through JSX. Don’t concatenate unsanitized strings onto their output. Initial state embedded in the page needs its own escaping, because JSON.stringify leaves a </script> sequence intact and an attacker can use that to close your script tag. Use serialize-javascript, or escape the angle brackets yourself:
const json = JSON.stringify(initialState).replace(/</g, '\\u003c');
Secrets, tokens and client-side state
- Everything shipped to the browser is public. That includes the bundle, environment variables prefixed with VITE_, REACT_APP_ or NEXT_PUBLIC_, React state and the Redux store, which anyone can inspect with browser extensions. API keys that need to stay secret belong on the server.
- Any script on the page can read localStorage, so one XSS bug is enough to steal tokens kept there. The safest setup is a backend-for-frontend that holds the tokens and gives the browser an HttpOnly cookie. If tokens have to live in JavaScript, keep them in memory and make them short-lived. redux-persist writes to localStorage by default, so exclude any slice that holds tokens or personal data.
- Service workers need HTTPS (localhost is the exception). Don’t cache authenticated API responses or personal data in Cache Storage, and clear the cache on logout.
- Checks in React only control what the user sees. Hiding an admin button doesn’t protect the admin endpoint, so the server has to authenticate and authorize every request. For login, use a maintained library from your identity provider, such as Auth0, Firebase Authentication or MSAL.
- If the app talks to its API with cookies, send an anti-CSRF token in a header on every state-changing request, and have the server reject requests where it’s missing.
- TypeScript catches wrongly shaped data at compile time. PropTypes checks are ignored in React 19. For data from APIs you don’t control, validate the shape at runtime with a library like zod.
npm security best practices
Supply chain attacks on npm are no longer hypothetical. In September 2025, attackers phished a maintainer’s account and pushed malware into chalk, debug and 16 other packages, which together get more than two billion downloads a week. A week later the Shai-Hulud worm spread through hundreds of packages. Its install script stole npm tokens from developers’ machines and used them to republish the victims’ own packages. Most of the advice below targets exactly those two paths.
Installing packages
- Commit your lockfile and install from it in CI with npm ci, which fails if package.json and the lockfile disagree. The equivalents are yarn install –frozen-lockfile in Yarn 1, yarn install –immutable in Yarn 2 and later, and pnpm install –frozen-lockfile.
- Turn off install scripts by default and allow only the few packages that need a build step. pnpm 10 already blocks dependency lifecycle scripts by default. With npm, set it in .npmrc or pass –ignore-scripts.
- Don’t install new versions the day they’re published. Malicious releases are usually caught and pulled within hours or days. pnpm’s minimumReleaseAge, Renovate’s minimumReleaseAge and Dependabot’s cooldown setting all enforce a waiting period. Read the changelog before a major upgrade.
- Run npm audit in CI, and npm audit signatures to verify registry signatures and provenance. Snyk and Socket add checks for packages that behave suspiciously, which a list of known CVEs won’t catch. npm outdated shows what’s behind, and npm doctor checks your npm setup.
- Before adding a dependency, look at who maintains it, how long it has existed, when it was last updated and what it pulls in. Watch for typosquatting: in 2017, a package called crossenv stole environment variables from people who meant to install cross-env. The same caution applies to third-party React components, which can contain unsafe DOM code or worse.
- Publish internal packages under a scope your organization owns on npm, and map that scope to your private registry. Otherwise a public package with the same name as an internal one can be installed in its place, which is called a dependency confusion attack. A proxy such as Verdaccio, Artifactory or Azure Artifacts also lets you cache and approve versions.
# .npmrc
ignore-scripts=true
@yourcompany:registry=https://npm.yourcompany.example/
Publishing packages
- Turn on two-factor authentication for your npm account in the “Authorization and writes” mode, which also requires 2FA to publish, and use a security key if you can.
- npm has retired classic tokens. Use granular tokens limited to specific packages, with a short expiry and an IP allowlist where possible. Better still, publish from GitHub Actions or GitLab CI with trusted publishing (OIDC), so no long-lived token exists for anyone to steal, and add provenance with npm publish –provenance.
- Keep secrets out of the published package. A files allowlist in package.json (for example “files”: [“dist”]) is safer than ignore files. Note that once a project has an .npmignore, npm stops reading .gitignore. Run npm pack –dry-run to see exactly what would be uploaded.
- Add a SECURITY.md that explains how to report vulnerabilities, and enable GitHub’s private vulnerability reporting. If you find a problem in someone else’s package, report it privately first and give the maintainers time to release a fix before you publish details. Report malicious packages to npm directly.
SQL database security best practices
These apply to SQL Server, PostgreSQL, MySQL and Oracle.
- Parameterize every query. ORMs like Entity Framework, Hibernate and Sequelize parameterize their normal query APIs, but their raw-query methods don’t always do it for you. Column names used for sorting can’t be parameters, so check them against an allowlist. Dynamic SQL inside a stored procedure is just as injectable as anywhere else; in SQL Server, use sp_executesql with parameters.
- Give the application its own login with read and write rights on its own schema, and nothing more: no db_owner, no DDL rights like DROP or ALTER. Run migrations under a separate account. If the app only calls stored procedures, grant EXECUTE on them and no direct table access. A SQL injection bug then has much less to work with.
- Keep the database off the public internet. Put it in a private subnet or behind a private endpoint, and allow its port only from the application servers. If the database runs on the same host as the app, bind it to localhost.
- Require encrypted connections with TLS 1.2 or later. Microsoft.Data.SqlClient 4.0 and later sets Encrypt=True by default. TrustServerCertificate=True turns certificate validation off, so install a trusted certificate on the server instead of adding that flag. In PostgreSQL, use hostssl rules in pg_hba.conf and sslmode=verify-full on the client. In MySQL, set require_secure_transport=ON.
- Never put database credentials in source code. Read them from a secret store or from configuration outside the webroot that isn’t in the repository. On Azure SQL and Amazon RDS you can use managed identities or IAM authentication and skip passwords entirely.
- Disable or rename default accounts such as sa, and don’t connect applications as root or postgres. Require MFA for people with administrative access.
- Encrypt data at rest with Transparent Data Encryption (SQL Server, Azure SQL, Oracle) or your cloud provider’s storage encryption. For especially sensitive columns, SQL Server’s Always Encrypted keeps values encrypted even from database administrators.
- Harden the server. Install updates (cumulative updates for SQL Server), run the database service under a low-privileged account, remove sample databases and unused features, and keep xp_cmdshell disabled. Put transaction logs on a separate disk from the data files.
- Turn on auditing (SQL Server Audit, pgAudit for PostgreSQL) and alert on failed logins, logins at unusual times and permission changes.
- Encrypt backups, keep one copy offsite or offline, and test restores. Until you’ve restored a backup, you don’t know whether it works.
NoSQL database security best practices
These apply to MongoDB, Cassandra and CouchDB.
- NoSQL injection usually comes through query operators. In a Node.js app that passes the request body straight into a query, an attacker can send an object where you expected a string:
// Attacker sends: { "token": { "$ne": null } }
// The query now matches the first user who has any reset token at all
const user = await users.findOne({ resetToken: req.body.token });
- Validate request shapes with a schema library such as zod or Joi, or at least cast values to strings. Avoid $where, $function and mapReduce with user input. If you don’t use server-side JavaScript at all, turn it off with security.javascriptEnabled: false.
- Turn on authentication. Self-managed MongoDB ships with authorization off, so set security.authorization: enabled and use SCRAM-SHA-256 or x.509 certificates. Cassandra’s default authenticator lets everyone in, so switch to PasswordAuthenticator and CassandraAuthorizer. CouchDB 3.x requires an admin account at setup. In every case, give each application a role limited to its own database.
- Limit network exposure. MongoDB has bound to localhost by default since version 3.6, and the ransom attacks of 2017, which wiped tens of thousands of open MongoDB servers, are a good reason not to change bindIp to 0.0.0.0. Be careful with Docker too. Publishing a port with -p 27017:27017 exposes it on every host interface, and Docker’s iptables rules bypass UFW. Use private networks, VPCs or an internal Docker network.
- Moving off the default port (27017 for MongoDB) cuts down on automated scanner noise, but it doesn’t protect you. Treat it as a minor extra, never as a substitute for authentication.
- Require TLS (net.tls.mode: requireTLS in MongoDB) and encrypt data at rest. For individual sensitive fields, MongoDB offers client-side field-level encryption and Queryable Encryption.
- A schema-less database will store whatever you send it. Validate in the application and add $jsonSchema validators to MongoDB collections as a second line of defense.
- Take backups that are consistent across replica sets and shards, encrypt them, and test restores. Turn on auditing where it exists: MongoDB Enterprise and Atlas have it, and Cassandra has included audit logging since 4.0.
Server, OS and physical security
In the cloud, most of this is your provider’s job. On your own hardware it’s yours. Keep servers and network gear in a locked room and store backup media offsite. Build servers from a hardened baseline such as the CIS Benchmarks, install OS updates after testing them, and disable services and features you don’t use. Run database services under low-privileged accounts, and restrict access to the data, log and binary files the database uses. On Windows servers, App Control for Business (formerly Windows Defender Application Control) can block unapproved executables. If IIS runs on the same machine as the database, harden it as well.
Where to start
If you’re reviewing an existing app, I’d start with the checks behind the most common breaches: object-level authorization on every API endpoint, parameterized queries everywhere, no secrets in the repository, and lockfile and install-script settings for npm. After that, work through security headers, rate limiting and database network rules. The resources below cover each area in more depth than a checklist can.
Further reading and resources
OWASP .NET Security Cheat Sheet
ASP.NET Core security topics | Microsoft Learn
OWASP NPM Security Cheat Sheet
OWASP Cross-Site Scripting Prevention Cheat Sheet
OWASP Database Security Cheat Sheet
SQL Server security best practices | Microsoft Learn
NIST SP 800-63B: Authentication and Authenticator Management
