ASVS 5.0 requirement V3.3.4 says that any cookie whose value client-side scripts have no business reading, a session token being the standard example, must carry the HttpOnly attribute. It also says the same value must reach the browser only through the Set-Cookie response header, never through a response body, a script variable, or anything else. It is a Level 2 requirement.
In plain words: if JavaScript cannot see the token, an XSS bug cannot steal it. And if the token also shows up somewhere else, such as a JSON login response or an inline script, the HttpOnly flag protects nothing.
Why it matters
Cross-site scripting is the most common way attackers run code in your users' browsers. Once they can run code, document.cookie is the first thing they read. A session token without HttpOnly can be copied to an attacker's server in one line, and the attacker then replays it from anywhere until it expires. See CWE-1004 for the weakness entry.
HttpOnly does not stop XSS. The injected script can still make requests as the user from inside the page. What it removes is the portable, long-lived theft: the attacker loses the ability to take the session away and use it later. That difference matters a lot for incident response, because a stolen token you can revoke is a very different problem from an attacker acting live in a browser tab.
The second half of the requirement catches a pattern that undoes the first half. A single page application logs in, the API returns {"token": "..."} in the body, and the front end stores it in localStorage or copies it into a readable cookie. Now the token is exactly as exposed as if HttpOnly had never been set.
How to implement it
Set the flag where the cookie is created, and set Secure and SameSite at the same time so the cookie is configured once, correctly.
Express with the express-session package:
app.use(session({
name: "sid",
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
httpOnly: true,
secure: true,
sameSite: "lax",
maxAge: 30 * 60 * 1000
}
}));
Django settings:
SESSION_COOKIE_HTTPONLY = True # default, keep it that way
SESSION_COOKIE_SECURE = True
SESSION_COOKIE_SAMESITE = "Lax"
CSRF_COOKIE_HTTPONLY = False # only if your JS must read the CSRF token
Spring Boot:
server:
servlet:
session:
cookie:
http-only: true
secure: true
same-site: lax
Raw header, for any stack:
Set-Cookie: sid=9f2c...; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=1800
Then remove every other path the value can take:
- Do not return the session token in a JSON body or redirect URL.
- Do not render it into HTML or an inline script.
- Do not log it, and do not copy it into a second cookie that scripts can read.
- If a front end needs to know whether the user is signed in, give it a separate non-sensitive signal, such as a
/meendpoint that returns the user profile.
For token based APIs that use bearer tokens in headers, the requirement still applies to whatever cookie holds a refresh token. Keep refresh tokens in an HttpOnly cookie scoped to the refresh path.
Click through it at your own pace, or open it full screen.
How to test it
Start in the browser. Log in, open developer tools, go to Application, then Cookies. Every session or authentication cookie should show a check mark in the HttpOnly column. Then open the console and run document.cookie. The session cookie name must not appear in the output.
Next, check the wire. With curl:
curl -si https://app.example.com/login -d "user=test&pass=test" | grep -i set-cookie
Every Set-Cookie line carrying a session value should include HttpOnly. Now test the second half. Capture the login response and search the body for the token value:
TOKEN=$(curl -si https://app.example.com/login -d "user=test&pass=test" \
| grep -i '^set-cookie: sid=' | sed 's/.*sid=\([^;]*\).*/\1/')
curl -s https://app.example.com/login -d "user=test&pass=test" | grep -c "$TOKEN"
A count above zero means the value is being echoed somewhere it should not be. Repeat the search against the pages you load after login, and check localStorage and sessionStorage in developer tools for anything that looks like a token.
A dynamic scan can automate the flag check across every response in a crawl. Safeguard's web target scan reports cookies missing attributes, and you can see how it presents them in the demo above.
Common mistakes
Setting the flag on the session cookie only. Refresh tokens, "remember me" cookies and API session cookies often get forgotten. Audit every Set-Cookie your app emits, including those from a reverse proxy, a load balancer sticky session, or a third party auth library.
Disabling HttpOnly to make something work. A front end that reads the session cookie to attach it as a header is a design problem, not a reason to drop the flag. Let the browser send the cookie automatically and use credentials: "include" on cross-origin calls where appropriate.
Echoing the token in the login response. Frameworks that return the session id in a body "for convenience" defeat the requirement. Return the user profile, not the credential.
Relying on HttpOnly as an XSS fix. It limits damage. You still need output encoding and a Content Security Policy. See V3.4.3 for the policy requirement.
Debug tooling. Error pages, request dumps and analytics that capture all request headers can copy the cookie into places scripts or third parties can read. Redact Cookie and Set-Cookie in logs.
Quick check
Does HttpOnly prevent XSS?
No. It prevents scripts from reading the cookie value. An injected script can still send authenticated requests from inside the page, so you still need to fix the injection.
My single page app needs the access token in JavaScript. Does V3.3.4 forbid that?
The requirement applies to values that are not meant to be accessible to scripts. If you decide the access token must be script readable, document that decision, keep its lifetime short, and keep the long-lived refresh token in an HttpOnly cookie.
Sources
- OWASP ASVS 5.0, requirement V3.3.4
- MDN: Set-Cookie header, HttpOnly attribute
- CWE-1004 on Gold