How to secure a web application: a layer-by-layer guide
The ten layers that protect a web application with users, with an Express example and how to check each one.
- 24 min read
A website with users has a frontend, an API and a login. From here on it is called a web application.
The 2025 edition of the OWASP Top 10 puts broken access control first and security misconfiguration second1. Several configuration failures already appear in the first draft of an Express application. Without any settings, it answers with X-Powered-By: Express and creates the session cookie with no security attributes. It also returns an error’s stack trace to the browser.
No single control protects the whole application. Each layer stops one specific threat, and the other layers cover what it lets through. The ten layers follow in order, and each one links to the article that goes deeper into it. The example uses Node.js and Express, but the layers and the curl checks hold for any framework.
The measurements were taken on 27 September 2026 with Node.js 24.19.0, Express 5.2.1, helmet 8.3.0, express-rate-limit 8.7.0, curl 8.12.1 and Chrome 153.0.8010.54. All the code is rebuilt for the article.
1. Which layers a web application has and what each one protects
| Layer | What it prevents | Main control | Go deeper |
|---|---|---|---|
| 1. HTTPS | Someone reading or changing the traffic | HTTPS and HSTS | Section 3 |
| 2. Security headers | Injected scripts and loading inside a foreign iframe |
CSP, frame-ancestors and nosniff |
Section 4 |
| 3. Session and cookies | Session theft | HttpOnly, Secure and SameSite |
Section 5 |
| 4. Passwords and attempts | Password theft and brute force | A slow hash and a per-account limit | Section 6 |
| 5. Permissions | Reading or changing someone else’s data | The check on the server | Eight things to check, sections 1 and 2 |
| 6. Input and output | Injection and XSS | Parameterised queries and escaped output | Eight things to check, sections 3, 5 and 8 |
| 7. Other origins (CORS and CSRF) | Another site reading the API or acting with the session | Allowlist of origins and anti-CSRF token | What CORS is |
| 8. Secrets | Keys exposed in the repository or the browser | Secrets out of the code and the bundle | Eight things to check, section 4 |
| 9. Dependencies | Third-party code that runs on install | Lockfile, signatures and blocked install scripts | npm and pnpm |
| 10. Errors and logs | Internal details in responses and data in logs | A generic response with an identifier | Eight things to check, section 7 |
Each layer is explained with what happens without it in a sample application and with the command that checks it. The linked articles show the same failure seen in the code, with its fix.
2. How to prepare the environment before writing code
If a coding agent writes part of the application, two settings in its environment reduce the risk from the start. The agent must not be able to read .env, and every change has to go through a review. First steps with AI coding explains both. How to ask an AI for secure code explains what to put in the request and what to check with the compiler, the linter and the tests.
How to do it. Before asking for the first feature, add to the project’s instructions file the layers from the table in section 1 that your application needs.
3. How to serve the application over HTTPS and enable HSTS
HTTPS encrypts the traffic between the browser and the server. HSTS (HTTP Strict Transport Security) tells the browser to always use HTTPS with that domain, for the number of seconds set by max-age2.
The server must only send the header over HTTPS2. If it arrives over HTTP, the browser ignores it. Over HTTP, the server only redirects:
// El servidor HTTP solo redirige a HTTPS.
const redirigir = (req, res) => {
res.writeHead(301, { Location: `https://localhost:3443${req.url}` }).end();
};
// HSTS solo por HTTPS: por HTTP no se envía (RFC 6797, 7.2).
if (req.secure) res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains');
OWASP recommends a max-age of two years, 63072000 seconds, and includes preload in its example3. The browsers’ preload list requires at least one year, includeSubDomains and the preload directive. However, that same list does not recommend preloading and asks you not to include preload by default4. That is why the example leaves it out.
How to do it.
- Enable HTTPS on the deployment platform or the proxy.
- Redirect every HTTP request with
301to the same path over HTTPS. - Send
Strict-Transport-Securityonly on HTTPS responses. If a proxy terminates HTTPS, setapp.set('trust proxy', 1): without it,req.secureis false and the header is not sent.
How to check it. The http:// address answers with the redirect and the https:// address carries the header:
$ curl -sI http://localhost:3000/
HTTP/1.1 301 Moved Permanently
Location: https://localhost:3443/
$ curl -skI https://localhost:3443/
HTTP/1.1 200 OK
Strict-Transport-Security: max-age=63072000; includeSubDomains
4. Which security headers to send
Security headers tell the browser what the page may run and load. Without any settings, the sample application sends none, and it announces the framework it uses:
HTTP/1.1 200 OK
X-Powered-By: Express
Content-Type: text/html; charset=utf-8
Alongside HSTS, OWASP recommends, among others, these three headers, written by hand here3:
res.setHeader('Content-Security-Policy', "default-src 'self'; frame-ancestors 'none'; form-action 'self'");
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
Content-Security-Policy(CSP) only allows loading resources from the page’s own origin. Withdefault-srcand without'unsafe-inline', the browser does not run scripts written inside the HTML5.frame-ancestors 'none'stops another site from loading the page inside aniframeto trick the user. It replacesX-Frame-Options3.X-Content-Type-Options: nosniffstops the browser from guessing a file’s type. It does not interpret as HTML or run as a script something served with another type3.Referrer-Policylimits which part of the URL is sent to other sites.strict-origin-when-cross-originis already the browsers’ default, and the header sets it explicitly.
On top of that, app.disable('x-powered-by') removes the header that announces Express. X-XSS-Protection is not sent: OWASP asks you not to send it or to send it with 03.
With the CSP active, Chrome blocks the script written inside the sample page’s HTML:
Executing inline script violates the following Content Security Policy directive
'default-src 'self''. Either the 'unsafe-inline' keyword, a hash
('sha256-Gl02dwIVyohd6RBcZoYZc3TfZvDUQjb/ZiHsJ3xX58U='), or a nonce ('nonce-...') is
required to enable inline execution. Note also that 'script-src' was not explicitly set, so
'default-src' is used as a fallback. The action has been blocked.
For a script of your own to run, move it to a .js file or allow it with the hash or nonce the message gives. Do not add 'unsafe-inline', because it cancels most of the protection5.
The helmet 8.3.0 package sets thirteen headers with a single line, app.use(helmet()): it sends twelve and removes X-Powered-By6. Three of its defaults differ from the example’s: Referrer-Policy: no-referrer, frame-ancestors 'self' and a one-year max-age. Adjust them with its options if you want the example’s.
How to check it. Request the headers of the home page:
$ curl -skI https://localhost:3443/
Content-Security-Policy: default-src 'self'; frame-ancestors 'none'; form-action 'self'
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
X-Powered-By does not appear. Open each kind of page with the browser console open and check that the CSP blocks none of your own scripts or styles.
5. How to protect the session and the cookies
The session cookie identifies the user on every request. Whoever gets it acts as that user. In the first draft, the cookie has no security attributes, and any script on the page reads it with document.cookie. The two lines come from two different logins:
Set-Cookie: sesion=8f81d9b1-ee09-471d-9134-ac51dd2b3c08; Path=/
document.cookie = "sesion=a093f210-c912-4b0b-9afe-23761b9c2a2f"
OWASP considers Secure and HttpOnly mandatory on the session cookie. It also asks you to set SameSite explicitly, preferably Strict, without relying on the browser’s default10:
res.cookie('__Host-sesion', id, { httpOnly: true, secure: true, sameSite: 'lax', path: '/' });
The example uses Lax so that a link from another site arrives with the session started. Use Strict if the application does not need that.
HttpOnlystops JavaScript from reading the cookie. An XSS can no longer copy it11.Secureonly sends it over HTTPS.SameSite=Laxdoes not send it on requests from other sites. The exception is top-level navigation with a safe method such asGET, for example a link to the application.Strictdoes not send it in that case either11.- The
__Host-prefix sets three conditions, or the browser rejects the cookie. It has to be set from an HTTPS origin, carrySecureandPath=/, and not carryDomain11. OWASP recommends it for the session10.
With these attributes, the same page no longer sees the session:
Set-Cookie: __Host-sesion=b6e6ed52-0028-45eb-9308-357f3e8a23dd; Path=/; HttpOnly; Secure; SameSite=Lax
document.cookie = ""
How to do it.
- Generate a new session identifier at login, as
randomUUID()does in the example. OWASP requires renewing it on every privilege change10. - Set
HttpOnly,Secure,SameSiteandPath=/, and use the__Host-prefix. - Expire the session on the server. OWASP suggests 15 to 30 minutes of inactivity for low-risk applications and 2 to 5 for high-value ones. For the absolute limit, it suggests 4 to 8 hours if the application is used through a working day10.
- At logout, delete the session on the server as well as the cookie. According to OWASP, invalidation on the server is the mandatory part10. If only the cookie is deleted, the identifier stays valid for anyone who copied it.
- If the frontend and the API are on different sites and need the cookie, read how to allow cookies across origins without opening the API.
How to check it. Log in with curl and read the Set-Cookie header. Then, with the session started in the browser, type document.cookie in the console: the session cookie must not appear. Finally, save the cookie, log out and repeat a request with the saved cookie: the response has to be 401.
6. How to store passwords and limit attempts
A password is stored as a slow hash, never in plain text or with a fast hash such as SHA-256. OWASP recommends Argon2id. If it is not available, it recommends scrypt; bcrypt only for legacy systems, and PBKDF2 if FIPS-140 is required12.
Node.js includes scrypt in node:crypto, with no dependencies. Its default parameters (N = 214, r = 8, p = 1) do not reach any of OWASP’s minimum combinations1213. The example uses the first one, N = 217, r = 8 and p = 1:
// Parámetros de scrypt: N = 2^17, r = 8, p = 1.
const PARAMS = { N: 2 ** 17, r: 8, p: 1, maxmem: 256 * 1024 * 1024 };
export async function hashContrasena(contrasena) {
const sal = randomBytes(16);
const hash = await scryptAsync(contrasena, sal, 64, PARAMS);
return `${sal.toString('hex')}:${hash.toString('hex')}`;
}
export async function verificarContrasena(contrasena, guardado) {
const [salHex, hashHex] = guardado.split(':');
const esperado = Buffer.from(hashHex, 'hex');
const hash = await scryptAsync(contrasena, Buffer.from(salHex, 'hex'), esperado.length, PARAMS);
return timingSafeEqual(hash, esperado);
}
These parameters need about 128 MiB. With scrypt’s default limit in Node.js (maxmem, 32 MiB), the call fails13:
ERR_CRYPTO_INVALID_SCRYPT_PARAMS Invalid scrypt params: error:030000AC:digital envelope
routines::memory limit exceeded
That is why the example raises maxmem to 256 MiB. With these parameters, computing one hash took about 260 ms. timingSafeEqual compares the result in a time that does not depend on where the two values differ13.
The server pays that cost too. OWASP warns that a work factor that is too high lets an attacker exhaust the server’s CPU with login requests12. With these parameters, each computation also reserves about 128 MiB. That is why the attempt limit below is mounted on the login route, ahead of the hash computation: app.post('/login', limite, iniciarSesion).
The per-account limit does not bound that load when many accounts are tried; the second limit, per IP, does. Also measure how many simultaneous logins your server can take before you settle on the parameters.
A slow hash does not stop someone trying passwords against the login. That is what an attempt limit is for. OWASP asks you to tie the failed-login counter to the account, not to the IP address14. skipSuccessfulRequests makes the limit count only failed attempts:
// Cinco intentos por cuenta cada 15 minutos (OWASP asocia el contador a la cuenta, no a la IP).
const limite = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: 'draft-8',
legacyHeaders: false,
keyGenerator: (req) => String(req.body?.email ?? '').toLowerCase(),
skipSuccessfulRequests: true
});
After one correct login and five wrong passwords, the sixth failed attempt answers 429, which means too many requests15:
HTTP/1.1 401 Unauthorized RateLimit: "5-in-15min"; r=0; t=899
HTTP/1.1 429 Too Many Requests RateLimit: "5-in-15min"; r=0; t=898 Retry-After: 898
One account’s limit does not affect the others: right afterwards, an attempt with another account answered 401, not 429.
For the same reason, the per-account limit does not stop password spraying, which OWASP defines as testing a single weak password against a large number of different accounts16. Each account receives one attempt, and no counter reaches five. OWASP recommends not relying on a single predictable volume limit16. Add a second, higher limit on the failed attempts from each IP, and review the logs when logins fail on many accounts at once.
Two details of the example remain. If the request carries no email, the key is an empty string and all those requests share one counter, so reject a request without email before the limit. Also, express-rate-limit’s default store keeps the counters in the process memory and does not share them between processes or servers17. With more than one instance, use a shared store, such as Redis.
Locking an account also lets an attacker lock out its owner. That is why the limit lasts a few minutes, although an attacker can renew it. OWASP suggests allowing access through password recovery even when the account is locked. The best defence against most password attacks, including brute force, is multi-factor authentication14.
How to check it. Send six attempts with a wrong password and check that the sixth answers 429. Then look up a stored password in the database: it must include the salt and look nothing like the password. In the example it has the form sal:hash; with Argon2id it starts with $argon2id$.
7. How to check permissions on the server
Broken access control is the first failure in the OWASP Top 10:20251. A hidden button in the frontend does not prevent the action, because anyone can send the request without the interface. The API has to check on every request who makes it and whether that user may do that action on that resource.
Eight things to check in the code your AI writes shows the two most common failures in the code, with their fixes. Section 1 covers the permission that is only checked in the interface. Section 2 covers the resource loaded by id without checking who owns it.
How to check it. Request an admin action with a normal user’s session: it must answer 403. Request a resource from another account by its id: it must answer 404.
8. How to handle input and output
Input is everything the user sends, and output is what the application returns to the browser. In the sample application, the search returns the text as it is. This URL injects an image with an onerror attribute:
https://localhost:3443/?q=<img src=x onerror="console.log('XSS ejecutado')">
Without protection, Chrome runs the attribute’s code and the console shows XSS ejecutado. That code could read the session or send requests on the user’s behalf. This is a reflected XSS.
The main defence is to encode the output for its context, or to let the framework do it. The CSP from section 4 is an extra layer, not the main defence20. In HTML content and quoted attributes, encoding the output means escaping five characters20:
export function escaparHtml(texto) {
return texto
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"')
.replaceAll("'", ''');
}
With the output escaped, the HTML the browser receives carries the encoded text, and the page shows it as text without running it:
Resultados para: <img src=x onerror="console.log('XSS ejecutado')">
Input is validated on the server, against a list of what is accepted21. Validation does not replace parameterised queries or escaped output. Eight things to check shows the query built without concatenating text (section 3) and the validation in the endpoint (section 5).
How to check it. Type <img src=x onerror=alert(1)> into every field that is later shown on the page: it must appear as text. Type a single quote into a search field: it must not produce a 500 error.
9. How to control requests from other origins
Another site open in the user’s browser can send requests to your API. CORS decides whether that site can read the response. What CORS is explains how to configure it with an allowlist of origins, and has a simulator to test each configuration.
CORS does not stop the request from arriving. If the API uses cookies, another site can submit a form that changes data with the user’s session. That is CSRF (Cross-Site Request Forgery). OWASP asks you to use the framework’s protection first and, if there is none, an anti-CSRF token on every request that changes data22.
If the application only supports modern browsers, OWASP also accepts the Sec-Fetch-Site header, which the browser sends with the value cross-site when the request comes from another site. In that case it requires checking Origin or Referer too, because some older browsers do not send Sec-Fetch-Site22. SameSite is an extra layer, and with Lax it does not protect an action that changes data with a GET request22.
How to check it. Send a request with a foreign origin and check that the response carries no Access-Control-Allow-Origin. Then send a request that changes data without the token: it must fail.
10. How to keep secrets out of the code and the browser
A secret is an API key, a database password or any value that grants access to a service. OWASP warns that many end up written in the source code, and recommends detecting them before the commit23.
How to do it.
- Keep
.envout of the repository with.gitignore. - Store production secrets in the deployment platform’s secrets manager. In containers, OWASP does not recommend passing them through environment variables if another method exists23.
- Add a secret detector to the pre-commit hook and to continuous integration.
- Check that no private key reaches the browser’s JavaScript. Eight things to check, section 4, explains how it happens with Next.js public variables and how to search for it in the bundle.
How to check it. git ls-files .env must return nothing. If a secret reached a commit, revoke it and generate another: deleting it from the file does not delete it from the history.
11. How to control dependencies
Dependencies are third-party code that runs with the same permissions as the application. The 2025 edition of the OWASP Top 10 covers them in a new category, software supply chain failures. It expands the 2021 category of vulnerable and outdated components1.
npm vs pnpm 11: install script security and allowBuilds measures which code the install runs and how to configure npm to block it. Eight things to check, section 6, explains how to check that a package the AI proposes exists before installing it.
How to check it. Always install with the lockfile and verify the signatures:
$ npm ci
found 0 vulnerabilities
$ npm audit signatures
71 packages have verified registry signatures
3 packages have verified attestations
12. How to answer errors without leaking data
An unhandled error shows information that helps an attacker. In the first draft, the error handler returns err.stack to the browser when the database fails:
HTTP/1.1 500 Internal Server Error
X-Powered-By: Express
Error: connect ECONNREFUSED 10.0.0.12:5432 (usuario tienda_admin)
at file:///C:/…/tienda-segura/src/index.js:71:9
The response reveals the database’s internal IP, a user name and the code’s path on the server. Express’s default error handler only hides the stack trace if NODE_ENV is production24. OWASP asks for a generic response, with the details kept on the server25:
function errorSeguro(err, req, res, _next) {
const id = randomUUID();
console.error(JSON.stringify({ id, ruta: req.path, error: err.message }));
res.status(500).json({ error: 'error interno', id });
}
With the handler, the browser only gets an identifier, and the same identifier appears in the server log with the details:
HTTP/1.1 500 Internal Server Error
{"error":"error interno","id":"09910789-0f1c-4dc8-914b-fe9a471ca607"}
{"id":"09910789-0f1c-4dc8-914b-fe9a471ca607","ruta":"/api/fallo","error":"connect ECONNREFUSED 10.0.0.12:5432 (usuario tienda_admin)"}
The log must not store passwords, session identifiers or tokens either26.
How to check it. Trigger an error, for example with the database stopped, and check that the response only carries the identifier. Look for that identifier in the server log.
13. How to check the whole application
The checklist brings together the checks from sections 3 to 12, by layer. Each layer links to its section. The ticks are saved in this browser so you can come back later.
Checklist
0 of 24 checked
Tick each item after checking it on your application. Replace your-domain.example with your own.
14. How to audit your project with a prompt
A coding agent can go through the ten layers in your project. Copy the prompt with the Copy prompt button in the Audit security panel and paste it into a session opened at the project root. The agent follows seven phases:
- It explores the project without writing anything: the framework, how it authenticates and where each layer is configured.
- It diagnoses the ten layers in the order of the checklist and ranks each failure by severity.
- It proposes and waits. It presents the failures and the changes, and stops until you confirm.
- It fixes layer by layer, with small changes.
- It adds tests and checks that they fail before the fix.
- It repeats the checks from the checklist and pastes the outputs.
- It delivers a report with what changed, what it could not check and the article that goes deeper into each pending layer.
The prompt was run once, as a demonstration, with Claude Code 2.1.283 and the previous version of the prompt. The project was a sample Express shop that was missing almost every layer. It took 10 minutes, not counting the wait for confirmation: 4 up to the proposal and 6 to fix and check.
In the diagnosis it found thirteen failures, and one more while fixing. Among them were a payment key written in the code, another user’s order readable by its id and SHA-256 passwords. The 13 tests it wrote failed before the fixes, and afterwards they all pass, along with the one that already existed. What depended on the production proxy, such as the number of hops for trust proxy, it noted as unverified.
A single run on a prepared project shows how the prompt works, but it does not measure how many failures it finds in a real project. Review the report as you would review one written by a person.
How to check it. git status and git diff show only configuration and security changes and the new test files, and the tests pass. The checks in the checklist from section 13 give the expected result.
What falls outside the ten layers
The ten layers cover the most common failures of an application with users, but not all of them. The OWASP Top 10:2025 includes others that are not developed here1:
- Logging and alerting (A09:2025). Storing errors is not enough: someone has to be alerted when failed logins or
403responses repeat. - Requests the server makes to addresses the user chooses, or SSRF. In the 2025 edition it is part of broken access control, A0127. It affects any feature that downloads a URL, such as a link preview or an import. Section 5 of the most common web attacks reproduces it and fixes it.
- Exceptional conditions (A10:2025): errors that leave the application in an open state, such as a
catchthat carries on. Section 12 only covers the response the browser receives. - File uploads: the type, the size, the name and where they are served from. OWASP covers it in its File Upload Cheat Sheet28.
- Multi-factor authentication: section 6 cites it as the best defence against password attacks, but does not explain how to implement it.
- Backups and recovery, which do not depend on the application’s code.
Where to go next
If you are starting from scratch, first prepare the environment with First steps with AI coding. Then use the table in section 1 to decide which layers your application needs.
To review the code layer by layer, read Eight things to check in the code your AI writes. For requests across origins, read What CORS is, and for dependencies, npm vs pnpm. The most common web attacks shows fourteen attacks against these layers, each with the request that exploits it and its fix.
References
- OWASP. OWASP Top 10:2025, Introductiontop10.owasp.org↩
- IETF. RFC 6797, HTTP Strict Transport Security (HSTS)rfc-editor.org↩
- OWASP. HTTP Security Response Headers Cheat Sheetcheatsheetseries.owasp.org↩
- Chromium. HSTS Preload List Submissionhstspreload.org↩
- MDN. Content Security Policy (CSP)developer.mozilla.org↩
- helmet. helmet, READMEnpmjs.com↩
- MDN. Content-Security-Policy, style-srcdeveloper.mozilla.org↩
- MDN. Content-Security-Policy-Report-Onlydeveloper.mozilla.org↩
- Next.js. How to set a Content Security Policy (CSP) for your Next.js applicationnextjs.org↩
- OWASP. Session Management Cheat Sheetcheatsheetseries.owasp.org↩
- MDN. Set-Cookiedeveloper.mozilla.org↩
- OWASP. Password Storage Cheat Sheetcheatsheetseries.owasp.org↩
- Node.js. Crypto: crypto.scrypt and crypto.timingSafeEqualnodejs.org↩
- OWASP. Authentication Cheat Sheetcheatsheetseries.owasp.org↩
- IETF. RFC 6585, Additional HTTP Status Codes, 429 Too Many Requestsrfc-editor.org↩
- OWASP. Credential Stuffing Prevention Cheat Sheetcheatsheetseries.owasp.org↩
- express-rate-limit. Data storesexpress-rate-limit.mintlify.app↩
- express-rate-limit. Troubleshooting proxy issuesexpress-rate-limit.mintlify.app↩
- express-rate-limit. Error codes, ERR_ERL_PERMISSIVE_TRUST_PROXYexpress-rate-limit.mintlify.app↩
- OWASP. Cross Site Scripting Prevention Cheat Sheetcheatsheetseries.owasp.org↩
- OWASP. Input Validation Cheat Sheetcheatsheetseries.owasp.org↩
- OWASP. Cross-Site Request Forgery Prevention Cheat Sheetcheatsheetseries.owasp.org↩
- OWASP. Secrets Management Cheat Sheetcheatsheetseries.owasp.org↩
- Express. Error handling, the default error handlerexpressjs.com↩
- OWASP. Error Handling Cheat Sheetcheatsheetseries.owasp.org↩
- OWASP. Logging Cheat Sheetcheatsheetseries.owasp.org↩
- OWASP. A01:2025 Broken Access Controlowasp.org↩
- OWASP. File Upload Cheat Sheetcheatsheetseries.owasp.org↩