Cookies Missing Secure, HttpOnly or SameSite
Checks the security flags on cookies the home page sets.
- Why It Matters
- Without Secure, cookies leak over plain HTTP. Without HttpOnly, any XSS bug can steal sessions. Without SameSite, cross-site request forgery is easier.
- How It Detects
- Inspects every Set-Cookie header on each site's home page for the Secure (HTTPS sites), HttpOnly and SameSite attributes. Cookies being deleted (expired or Max-Age 0) are ignored, and anti-CSRF cookies such as XSRF-TOKEN are not required to be HttpOnly because scripts must read them. Cookies set on redirect responses are evaluated too, because browsers store them. Cookie values are never recorded.
- How to Fix
- PHP: session.cookie_secure=1, session.cookie_httponly=1, session.cookie_samesite=Lax in php.ini . Frameworks have equivalent settings (e.g. Laravel config/session.php). Use HttpOnly for every cookie JavaScript does not need to read.
- How to Verify
curl -sI https://<site>/ | grep -i set-cookie- CVSS v3.1 Vector
CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:L/A:N- Control Category
- Web Application Security: CIS Controls v8 16; NIST CSF 2.0 PR.PS, PR.DS
- Fix Effort
- Medium
- CIS Controls v8 Safeguard
- 16.7
- NIST CSF 2.0 Subcategory
- PR.PS-01
- OWASP WSTG v4.2
- WSTG-SESS-02
- CWE
- CWE-614, CWE-1004, CWE-1275
- OWASP Top 10
- A05:2021, A07:2021
- OWASP ASVS 4.0.3
- 3.4.1, 3.4.2, 3.4.3
- NIST SP 800-53 Rev. 5
- SC-8, SC-23
- References
- OWASP Session Management Cheat Sheet
RFC 6265, HTTP State Management Mechanism
Source Code
The complete file that defines this check. Run it with openvapt scan --only WEB-CK-001, or read it offline with openvapt explain WEB-CK-001.
internal/checks/web/transport.go · Lines 1-127View on GitHub at This Commit
| 1 | package web |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "errors" |
| 6 | "io/fs" |
| 7 | "net/http" |
| 8 | "strings" |
| 9 | "syscall" |
| 10 | "time" |
| 11 | |
| 12 | "github.com/openvapt/openvapt/internal/check" |
| 13 | "github.com/openvapt/openvapt/internal/sys" |
| 14 | ) |
| 15 | |
| 16 | func init() { |
| 17 | check.Register(&check.Check{ |
| 18 | ID: "WEB-TLS-001", |
| 19 | Title: "Site Available Over Plain HTTP Without Redirect", |
| 20 | Domain: check.DomainWeb, |
| 21 | Severity: check.Medium, |
| 22 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:L/A:N", |
| 23 | Summary: "Checks that every site redirects http:// requests to https://.", |
| 24 | Rationale: "Pages served over plain HTTP can be read and changed by anyone on the network path, including injected malware and stolen session cookies.", |
| 25 | Detection: "Requests http://<site>/ through 127.0.0.1 without following redirects. A 301/302/307/308 to an https:// URL passes, as does a refused connection (nothing listens on port 80). Any other response fails. A timeout or other error is Incomplete, because a slow or half-open port 80 proves nothing.", |
| 26 | Fix: "Apache: in the port 80 vhost add Redirect permanent / https://<site>/ . nginx: return 301 https://$host$request_uri; . Get a free certificate with certbot if HTTPS is not set up yet.", |
| 27 | Verify: "curl -sI http://<site>/ should show a 301 with Location: https://...", |
| 28 | Refs: []check.Ref{{Title: "OWASP Transport Layer Security Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Security_Cheat_Sheet.html"}}, |
| 29 | Standards: check.Standards{CWE: []string{"CWE-319"}, OWASP: []string{"A02:2021"}, ASVS: []string{"9.1.1"}, WSTG: []string{"WSTG-CRYP-01"}, NIST: []string{"SC-8"}}, |
| 30 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 31 | return ForEachSite(ctx, s, func(st Site) check.Result { |
| 32 | r, err := s.HTTP(ctx, "http", st.Name, "/") |
| 33 | if err != nil { |
| 34 | if notServed(err) { |
| 35 | return check.PassR(st.Name, "Plain HTTP is not served (connection refused on port 80)") |
| 36 | } |
| 37 | return check.IncompleteR(st.Name, "Plain HTTP could not be assessed: the request to port 80 failed without a refusal (timeout or other error), so it is unknown whether the site is served over HTTP", err.Error()) |
| 38 | } |
| 39 | loc := r.Header.Get("Location") |
| 40 | if r.Status >= 300 && r.Status < 400 && strings.HasPrefix(strings.ToLower(loc), "https://") { |
| 41 | return check.PassR(st.Name, "HTTP redirects to HTTPS", loc) |
| 42 | } |
| 43 | return check.FailR(st.Name, "Site content is served over plain HTTP", "HTTP status "+itoa(r.Status)) |
| 44 | }) |
| 45 | }, |
| 46 | }) |
| 47 | |
| 48 | check.Register(&check.Check{ |
| 49 | ID: "WEB-CK-001", |
| 50 | Title: "Cookies Missing Secure, HttpOnly or SameSite", |
| 51 | Domain: check.DomainWeb, |
| 52 | Severity: check.Medium, |
| 53 | CVSS: "CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:L/A:N", |
| 54 | Summary: "Checks the security flags on cookies the home page sets.", |
| 55 | Rationale: "Without Secure, cookies leak over plain HTTP. Without HttpOnly, any XSS bug can steal sessions. Without SameSite, cross-site request forgery is easier.", |
| 56 | Detection: "Inspects every Set-Cookie header on each site's home page for the Secure (HTTPS sites), HttpOnly and SameSite attributes. Cookies being deleted (expired or Max-Age 0) are ignored, and anti-CSRF cookies such as XSRF-TOKEN are not required to be HttpOnly because scripts must read them. Cookies set on redirect responses are evaluated too, because browsers store them. Cookie values are never recorded.", |
| 57 | Fix: "PHP: session.cookie_secure=1, session.cookie_httponly=1, session.cookie_samesite=Lax in php.ini . Frameworks have equivalent settings (e.g. Laravel config/session.php). Use HttpOnly for every cookie JavaScript does not need to read.", |
| 58 | Verify: "curl -sI https://<site>/ | grep -i set-cookie", |
| 59 | Refs: []check.Ref{{Title: "OWASP Session Management Cheat Sheet", URL: "https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html"}, {Title: "RFC 6265, HTTP State Management Mechanism", URL: "https://www.rfc-editor.org/rfc/rfc6265.html"}}, |
| 60 | Standards: check.Standards{CWE: []string{"CWE-614", "CWE-1004", "CWE-1275"}, OWASP: []string{"A05:2021", "A07:2021"}, ASVS: []string{"3.4.1", "3.4.2", "3.4.3"}, WSTG: []string{"WSTG-SESS-02"}, NIST: []string{"SC-8", "SC-23"}}, |
| 61 | Run: func(ctx context.Context, s sys.System) []check.Result { |
| 62 | return ForEachSite(ctx, s, func(st Site) check.Result { |
| 63 | // Cookies set on a redirect are stored by the browser like |
| 64 | // any other, so a 3xx home page is evaluated too. |
| 65 | if len(st.Home.Cookies) == 0 { |
| 66 | return check.PassR(st.Name, "Home page sets no cookies") |
| 67 | } |
| 68 | var bad []string |
| 69 | for _, c := range st.Home.Cookies { |
| 70 | if deletionCookie(c) { |
| 71 | continue // a cookie being cleared carries no value to protect |
| 72 | } |
| 73 | var miss []string |
| 74 | if st.HTTPS && !c.Secure { |
| 75 | miss = append(miss, "Secure") |
| 76 | } |
| 77 | if !c.HttpOnly && !csrfCookie(c.Name) { |
| 78 | miss = append(miss, "HttpOnly") |
| 79 | } |
| 80 | if c.SameSite == 0 { |
| 81 | miss = append(miss, "SameSite") |
| 82 | } |
| 83 | if len(miss) > 0 { |
| 84 | bad = append(bad, c.Name+": missing "+strings.Join(miss, ", ")) |
| 85 | } |
| 86 | } |
| 87 | if len(bad) > 0 { |
| 88 | return check.FailR(st.Name, "Cookies are missing security flags", bad...) |
| 89 | } |
| 90 | return check.PassR(st.Name, "All cookies carry Secure, HttpOnly and SameSite") |
| 91 | }) |
| 92 | }, |
| 93 | }) |
| 94 | } |
| 95 | |
| 96 | // notServed reports an error meaning nothing listens on the port: the kernel |
| 97 | // refused the connection, or a simulated system has no response for it. |
| 98 | func notServed(err error) bool { |
| 99 | return errors.Is(err, syscall.ECONNREFUSED) || errors.Is(err, fs.ErrNotExist) |
| 100 | } |
| 101 | |
| 102 | func itoa(n int) string { |
| 103 | if n == 0 { |
| 104 | return "0" |
| 105 | } |
| 106 | var b []byte |
| 107 | for n > 0 { |
| 108 | b = append([]byte{byte('0' + n%10)}, b...) |
| 109 | n /= 10 |
| 110 | } |
| 111 | return string(b) |
| 112 | } |
| 113 | |
| 114 | // deletionCookie reports a Set-Cookie that clears a cookie: Max-Age 0 or a |
| 115 | // negative value (net/http reports both as MaxAge < 0), or an expiry date in |
| 116 | // the past. |
| 117 | func deletionCookie(c *http.Cookie) bool { |
| 118 | return c.MaxAge < 0 || !c.Expires.IsZero() && c.Expires.Before(time.Now()) |
| 119 | } |
| 120 | |
| 121 | // csrfCookie reports an anti-CSRF token cookie (for example XSRF-TOKEN or |
| 122 | // csrftoken). The double-submit pattern needs JavaScript to read it, so |
| 123 | // HttpOnly is not expected. |
| 124 | func csrfCookie(name string) bool { |
| 125 | n := strings.ToLower(name) |
| 126 | return strings.Contains(n, "xsrf") || strings.Contains(n, "csrf") |
| 127 | } |
Also available as plain Markdown for notes, scripts and AI tools. Same facts, same source, no styling.
Cite This Page
If this page helped your work, please cite it. The version and web address are filled in; the access date is today's date in your browser. Every other style, and when credit is required, is on How to Cite OpenVAPT.
OpenVAPT. (2026). WEB-CK-001 Cookies Missing Secure, HttpOnly or SameSite (Version 0.1.0-alpha.6). OpenVAPT Check Catalogue. Retrieved October 16, 2026, from https://openvapt.com/checks/WEB-CK-001.html
In text: (OpenVAPT, 2026). Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
@misc{openvapt_web_ck_001,
author = {{OpenVAPT}},
title = {{WEB-CK-001 Cookies Missing Secure, HttpOnly or SameSite}},
year = {2026},
howpublished = {\url{https://openvapt.com/checks/WEB-CK-001.html}},
note = {OpenVAPT Check Catalogue, version 0.1.0-alpha.6. Accessed 16 October 2026}
}
Accessed: the date shown is today's date from your own browser (the site's build date without JavaScript); change it to the day you read the page.
Citing OpenVAPT does not mean OpenVAPT endorses, approves or has reviewed your work.