July 3, 2026
How to Apply OWASP ASVS in Your Code, A Developer’s Verification Checklist
Mapping of OWASP ASVS requirements for auth, injection, CORS, business logic, request smuggling to real Express/Node examples, plus how to…

By FOLAKE SOWONOYE
7 min read
Mapping of OWASP ASVS requirements for auth, injection, CORS, business logic, request smuggling to real Express/Node examples, plus how to pick your verification level.
Your Code review is clean. Tests pass. And your login form is still vulnerable to account enumeration, your checkout page is one missing header away from a script-injection incident, and nobody can tell you why because "secure" was never written down as something to verify, only something to hope for.
That's the gap OWASP's Application Security Verification Standard (ASVS) closes. If you're the developer or lead who has to answer "is this actually secure?" with more than a shrug, ASVS is what turns that answer into something you can point to a numbered requirement and the exact code that satisfies it, not a vibe. The OWASP Top 10 tells you what tends to break. ASVS tells you, requirement by requirement, what "not broken" actually looks like and gives you a number to point to when someone asks "did we check for that?"
What you don't know
Most teams already know the categories: injection, broken authentication, security misconfiguration, cross-site scripting. That's not the problem. The problem is that "avoid XSS" isn't actionable, it's actually a slogan. Developers need a concrete, testable statement like cookies must carry the Secure, HttpOnly, and an appropriate SameSite attribute, not a category name. Without that specificity, security becomes a code-review vibe check instead of a checklist, and vibe checks miss things especially under deadline pressure, and increasingly in AI-generated code that looks correct but was never verified against a standard. I covered that exact failure mode in my piece on scanning AI-generated code before commit the pattern is the same: plausible-looking code isn't the same as verified code.
ASVS is roughly 350 requirements across 17 chapters, split into three verification levels (L1, L2, L3), so you don't need to memorize it you need to know which applies to your app, and what each requirement looks like in real code.
Use Case 1: The Login Form
The OWASP Top 10 category "Identification and Authentication Failures" maps directly onto ASVS chapter 6. A handful of requirements matter immediately:
6.2.5 / 6.2.9 — accept passwords of any composition, no forced character-class rules, and support at least 64 characters. Composition rules push users toward predictable patterns; length does more for entropy than complexity ever will.
6.3.2 — no default accounts (admin, root, sa) left active anywhere in the stack, including staging.
A minimal Express password-policy check that actually follows this, instead of the classic (and counterproductive) uppercase/number/symbol regex:
function isValidPassword(pw) {
// Length-based, not composition-based — matches ASVS 6.2.1/6.2.5/6.2.9
return typeof pw === "string" && pw.length >= 8 && pw.length <= 128;
}function isValidPassword(pw) {
// Length-based, not composition-based — matches ASVS 6.2.1/6.2.5/6.2.9
return typeof pw === "string" && pw.length >= 8 && pw.length <= 128;
}Then pair it with a breached password check (ASVS 6.2.12) using:
// Don't do complexity theater
if (!/(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%])/.test(password)) { /* reject */ }
// Do length + breach-list checking instead
if (password.length < 8) { /* reject */ }
const isBreached = await checkAgainstBreachList(password);
if (isBreached) { /* reject */ }// Don't do complexity theater
if (!/(?=.*[A-Z])(?=.*[0-9])(?=.*[!@#$%])/.test(password)) { /* reject */ }
// Do length + breach-list checking instead
if (password.length < 8) { /* reject */ }
const isBreached = await checkAgainstBreachList(password);
if (isBreached) { /* reject */ }That single check quietly kills most credential stuffing attempts before they start.
One more rule worth internalizing here because it contradicts a lot of legacy policy: don't force periodic password rotation (6.2.10). A user's password should stay valid until it's discovered to be compromised or the user chooses to rotate it because forced rotation pushes users toward predictable incremental patterns (Password1, Password2), which is a net loss for security, not a gain.
If your app supports federated login such as Google, Okta. ASVS 6.8.2 requires you to always validate the presence and integrity of digital signatures on authentication assertions (JWTs, SAML), rejecting anything unsigned or invalidly signed:
const jwt = require('jsonwebtoken');
function verifyIdToken(token, publicKey) {
try {
return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
} catch (err) {
throw new Error('Invalid or unsigned assertion — rejecting');
}
}const jwt = require('jsonwebtoken');
function verifyIdToken(token, publicKey) {
try {
return jwt.verify(token, publicKey, { algorithms: ['RS256'] });
} catch (err) {
throw new Error('Invalid or unsigned assertion — rejecting');
}
}Skipping this check is how identity spoofing happens even when the Identity Provider itself is completely secure, the vulnerability isn't in Google's login flow, it's in your app trusting an assertion it never actually verified.
Use Case 2: The Checkout Page
This is where "Injection" and "Security Misconfiguration" collide with real financial risk. I went deep on this in the PCI DSS v4.0.1 checkout hardening article, but here's the ASVS angle: requirement 3.4.3 says your HTTP responses need a Content-Security-Policy header restricting what the browser is allowed to load and execute, with object-src 'none' and base-uri 'none' as a baseline. That's not a nice-to-have but it's the control that stops a compromised third-party script from exfiltrating card data via injected JavaScript.
// Express middleware — minimum viable CSP per ASVS 3.4.3
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; object-src 'none'; base-uri 'none'; script-src 'self' https://js.stripe.com"
);
next();
});// Express middleware — minimum viable CSP per ASVS 3.4.3
app.use((req, res, next) => {
res.setHeader(
"Content-Security-Policy",
"default-src 'self'; object-src 'none'; base-uri 'none'; script-src 'self' https://js.stripe.com"
);
next();
});Pair that with 3.4.1 (cookies need Secure, and HttpOnly if they're not meant to be read by client-side scripts). Two details worth being precise about: if the cookie name doesn't use the __Host- prefix, it must use __Secure- instead, and SameSite should be set deliberately based on the cookie's actual purpose not left at whatever the framework defaults to:
res.cookie("session", token, {
secure: true,
httpOnly: true,
sameSite: "strict",
});res.cookie("session", token, {
secure: true,
httpOnly: true,
sameSite: "strict",
});Together, CSP and correctly scoped cookies close the two most common paths from "XSS proof of concept" to "actual data exfiltration."
CORS is the other half of this picture, ASVS requires a validated Access-Control-Allow-Origin value, if you reflect the request's Origin header, it must be checked against an explicit allowlist, never trusted blindly:
const ALLOWED_ORIGINS = ["https://app.example.com", "https://checkout.example.com"];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
next();
});const ALLOWED_ORIGINS = ["https://app.example.com", "https://checkout.example.com"];
app.use((req, res, next) => {
const origin = req.headers.origin;
if (ALLOWED_ORIGINS.includes(origin)) {
res.setHeader("Access-Control-Allow-Origin", origin);
}
next();
});Access-Control-Allow-Origin: * on any endpoint that returns sensitive data is a straightforward way to hand a third-party site read access to your users' data.
And on the method side: HTTP requests to sensitive functionality should use POST, PUT, PATCH, or DELETEnever a method the spec defines as "safe," like GET. A password reset or fund transfer triggered by a GET request is a CSRF vulnerability waiting to be exploited via a single <img> tag pointing at the URL.
On the injection side ASVS chapter 5's core message is positive validation at a trusted layer, never client-side only. For SQL, that means parameterized queries, full stop:
// Never do string concatenation for queries
const result = await db.query(
"SELECT * FROM orders WHERE user_id = $1 AND status = $2",
[userId, status]
);// Never do string concatenation for queries
const result = await db.query(
"SELECT * FROM orders WHERE user_id = $1 AND status = $2",
[userId, status]
);Client-side validation improves usability and should still be encouraged but it must never be relied on as the actual security boundary:
// Client-side validation: good UX, zero security value
if (!emailRegex.test(input)) showError();
// Server-side validation: the actual security control
app.post("/signup", (req, res) => {
const { email } = req.body;
if (!isValidEmail(email)) {
return res.status(400).json({ error: "Invalid email" });
}
// proceed
});// Client-side validation: good UX, zero security value
if (!emailRegex.test(input)) showError();
// Server-side validation: the actual security control
app.post("/signup", (req, res) => {
const { email } = req.body;
if (!isValidEmail(email)) {
return res.status(400).json({ error: "Invalid email" });
}
// proceed
});Injection Doesn't Stop at SQL
SQL injection gets all the attention, but ASVS chapter 5 covers a wider surface than most checklists mention and each one shows up in real applications more often than developers expect.
Lightweight Directory Access Protocol injection. LDAP(Organizations use it as a centralized database to store usernames, passwords, and access rights, allowing employees to access multiple systems with a single login). If user input is concatenated directly into an LDAP query, an attacker can manipulate the query logic the same way they would with SQL injection:
// Vulnerable: direct string concatenation into LDAP filter
const filter = `(uid=${username})`;
// Safe: escape LDAP special characters before building the filter
function escapeLDAP(input) {
return input.replace(/[\\*()\0/]/g, (char) => `\\${char.charCodeAt(0).toString(16)}`);
}
const filter = `(uid=${escapeLDAP(username)})`;// Vulnerable: direct string concatenation into LDAP filter
const filter = `(uid=${username})`;
// Safe: escape LDAP special characters before building the filter
function escapeLDAP(input) {
return input.replace(/[\\*()\0/]/g, (char) => `\\${char.charCodeAt(0).toString(16)}`);
}
const filter = `(uid=${escapeLDAP(username)})`;Regex injection. Special characters in regular expressions need escaping too typically with a backslash to stop user input from being misinterpreted as regex metacharacters when it's used to build a pattern dynamically. An unescaped . or * from user input can turn a simple search field into a denial-of-service vector (catastrophic backtracking) or a logic bypass.
Markdown and template injection — the one most teams miss entirely. ASVS requires applications to sanitize or disable user-supplied scriptable or expression template language content e.g Markdown, CSS. Most AI assistants and AI-powered chat interfaces render Markdown by default. That means AI output containing user-supplied content is a new injection surface, a user-submitted comment that gets fed into an AI summary, then rendered as Markdown, then displayed as HTML, has three separate points where an unescaped script tag can survive the pipeline:
// If rendering Markdown that may contain user input,
// sanitize the OUTPUT html, not just the markdown source
import { marked } from "marked";
import DOMPurify from "dompurify";
const rawHtml = marked.parse(userMarkdown);
const safeHtml = DOMPurify.sanitize(rawHtml);// If rendering Markdown that may contain user input,
// sanitize the OUTPUT html, not just the markdown source
import { marked } from "marked";
import DOMPurify from "dompurify";
const rawHtml = marked.parse(userMarkdown);
const safeHtml = DOMPurify.sanitize(rawHtml);Mail header injection. User input passed to mail systems needs sanitizing too, to protect against SMTP or IMAP injection a common attack where newline characters injected into a "From" or "Subject" field let an attacker hijack an email's recipients or content entirely.
Business Logic Integrity
Everything above is about validating data. ASVS chapter 11 goes a level deeper into validating behavior. Applications should only process business logic flows in the expected sequential order, without allowing steps to be skipped, a checkout that lets someone hit the "confirm order" endpoint directly without ever reaching payment is a logic flaw. Transactions at the business logic level should either succeed in their entirety or roll back completely, with no partial states left behind.
Business logic locking should prevent limited-quantity resources such as theater seats, delivery slots, inventory units from being double-booked by manipulating application logic, such as firing two simultaneous requests to exploit a race condition between the check and the reservation:
// Preventing double-booking with an atomic check-and-reserve
async function reserveSeat(seatId, userId) {
const result = await db.query(
`UPDATE seats SET reserved_by = $1
WHERE id = $2 AND reserved_by IS NULL
RETURNING id`,
[userId, seatId]
);
if (result.rowCount === 0) {
throw new Error("Seat already reserved");
}
return result.rows[0];
}// Preventing double-booking with an atomic check-and-reserve
async function reserveSeat(seatId, userId) {
const result = await db.query(
`UPDATE seats SET reserved_by = $1
WHERE id = $2 AND reserved_by IS NULL
RETURNING id`,
[userId, seatId]
);
if (result.rowCount === 0) {
throw new Error("Seat already reserved");
}
return result.rows[0];
}For high-value flows, large monetary transfers, contract approvals, access to classified information, safety overrides in manufacturing systems, ASVS goes further and requires multi-user approval to prevent unauthorized or accidental single-actor actions.
Why This Matters
Every requirement above has existed as "best practice" advice for years. What ASVS adds is a reference number which turns "we should probably sanitize that" into "this fails 3.4.3, ship blocker." This is important when you're trying to get a fix prioritized against a feature deadline, and when a client or auditor asks for evidence.
If you've already built a pre-commit scanning pipeline to catch AI-generated vulnerabilities, you have the infrastructure to start testing against these requirements systematically. A test suite for a login controller, for instance, should test the username field for default usernames, account enumeration, brute-forcing, LDAP and SQL injection, and XSS; the password field should be tested for common passwords, length limits, null byte injection, and parameter removal.
Deciding Your Level
ASVS's three levels are a scoping decision:
- L1 — the minimum baseline, mostly authentication and input handling. Appropriate for low-sensitivity apps.
- L2 — adds mandatory multi-factor authentication and defenses against less common attack classes. This is the realistic target for anything handling personal or payment data.
- L3 — hardware-backed authentication and defense-in-depth. A bank justifying anything less than this to its customers has a hard conversation ahead. Pick the level that matches what you're actually protecting and building, not the one that sounds most impressive in a security review.
Full standard: OWASP ASVS project page.