Secure Cookies in PHP — Let the Browser Carry Less Risk
A cookie looks small, but it has an unusual property: once a browser accepts it, the browser may attach it to later requests automatically. That is convenient for a login session, but convenience also means that an application must be precise about where the cookie may travel and which code may access it.
For a small PHP application, the useful question is not “which security flag should I copy?” It is: “what behavior do I need, and what is the smallest scope that supports it?” The cookie specification, the PHP setcookie() documentation, and security guidance from OWASP give us a good starting point.
What a Session Cookie Actually Does
HTTP does not remember a user between requests by itself. A server can send a Set-Cookie response header, and the browser can later return the value in a Cookie request header. In a common session design, the value is an opaque identifier. The server stores the user, expiration time, and authorization state separately, then looks up that identifier on each request.
This distinction matters. A cookie is not automatically a password, an encrypted container, or proof that a request is trustworthy. It is usually a bearer of state: whoever presents a valid session identifier may be treated as the session owner until the server rejects or expires it. Cookie attributes reduce some ways the identifier can leak or be sent. They do not make a stolen, still-valid identifier harmless.
Start with Secure and HttpOnly
Secure tells the browser to send the cookie only over a secure connection, normally HTTPS. It helps prevent a session identifier from being sent over an unencrypted HTTP request. It does not encrypt the value in every place it may exist, and it does not fix an application that accepts an attacker-controlled session identifier.
HttpOnly prevents ordinary browser scripts from reading the cookie through APIs such as document.cookie. This is useful when the cookie contains a session identifier. However, it is not an XSS cure. An injected script may still cause requests from the victim's browser, and the browser may attach the HttpOnly cookie to those requests. The flag protects the cookie's direct readability, not every action available to code running in the page.
These attributes solve different problems and can be used together:
Set-Cookie: __Host-Session=opaque-value; Secure; HttpOnly; SameSite=Lax; Path=/
The value above is only an example of a header shape. It is not a complete session implementation. The server still needs to generate unpredictable identifiers, validate them, enforce authorization, rotate them after authentication, and invalidate them at logout or expiration.
Choose SameSite Deliberately
The SameSite attribute controls whether the browser attaches a cookie to cross-site requests. “Same-site” and “same-origin” are related concepts, but they are not interchangeable. The current RFC 6265bis document is still an Internet-Draft, so its details should not be treated as a final RFC. Its description agrees with current browser-facing documentation on the three familiar values:
Strictis the narrowest choice. The cookie is withheld from cross-site requests, which can be inconvenient when users arrive at the site from another site.Laxallows same-site requests and some top-level navigations from another site, while excluding many cross-site subresource and unsafe-method requests.Noneallows cross-site delivery and must be combined withSecure. It is for cases that genuinely require cross-site behavior, not a default to make integration errors disappear.
For a normal first-party login session, Strict or Lax is usually a more constrained starting point than None. The correct choice depends on the application's navigation and integration requirements. OWASP describes SameSite as defense in depth for CSRF, not as a replacement for a server-checked CSRF token. A state-changing endpoint should still verify an appropriate CSRF defense and authorization.
Keep Domain and Path Small
If Domain is omitted, the cookie is host-only: it is intended for the host that set it. Adding Domain=example.com broadens delivery to subdomains such as admin.example.com and shop.example.com. That may be required for a shared application, but it also means that a weaker subdomain becomes more relevant to the cookie's security.
The smallest useful scope is generally the safer default. Do not add a Domain attribute merely because examples on the internet include one. If a session belongs only to app.example.com, allowing every subdomain to receive it increases the blast radius without adding value.
Path controls which URL paths receive the cookie. It can reduce unnecessary delivery, but it is not an access-control boundary. Code running on the same host may still interact with other paths, and the browser's cookie model has historical edge cases. Treat Path as routing scope and bandwidth hygiene, not as a substitute for authorization.
Do Not Rely on Browser Closure for Expiration
A cookie without Expires or Max-Age is commonly called a session cookie. That name can be misleading: browser session restoration may preserve it when a browser is closed and reopened. If a session must expire after a defined period, enforce that lifetime on the server as well.
For persistent cookies, Max-Age expresses a duration and takes precedence over Expires when both are present. To delete a cookie, the server must send a new cookie with the same name and matching scope, then set an expiration in the past or a non-positive Max-Age. Deleting the browser copy does not invalidate a copy that an attacker already obtained; the server must revoke or expire the associated session record.
Use PHP's Options Array
PHP has supported an options-array form of setcookie() since PHP 7.3. The official manual documents the expires, path, domain, secure, httponly, and samesite options. Calling it must happen before output because it produces an HTTP response header.
<?php
$sessionId = generate_session_id(); // Use a CSPRNG-backed implementation.
setcookie('__Host-Session', $sessionId, [
'expires' => 0, // A session cookie; enforce server-side expiry too.
'path' => '/',
'secure' => true,
'httponly' => true,
'samesite' => 'Lax',
]);
The generate_session_id() name is intentionally a placeholder, not a PHP function supplied by the language. The application must use a cryptographically secure generator or its framework's established session mechanism. It should not put a username, role, email address, or other sensitive data directly into the cookie merely because the browser can store it.
The __Host- prefix adds browser-enforced requirements in user agents that support cookie prefixes: the cookie must use Secure, must have Path=/, and must not have a Domain attribute. The prefix is a useful defense-in-depth signal, but it is not a replacement for testing browser support or server-side session controls.
What This Example Does Not Solve
A secure-looking Set-Cookie line can still sit inside an unsafe application. The server must compare the received identifier with a server-side session record, reject expired or revoked sessions, and regenerate the identifier after login or privilege changes to reduce session fixation risk. It should also avoid putting session identifiers in URLs, where they can appear in history, logs, bookmarks, or referrer data.
There is another important limit: HttpOnly does not stop XSS, SameSite does not replace CSRF validation, and Secure does not make an HTTP endpoint safe. These controls are layers. The application still needs output encoding, input handling, authorization checks, TLS across the session, and a logout path that invalidates server-side state.
A Small Review Checklist
- Is the session value opaque and generated with a suitable cryptographically secure mechanism?
- Does the session cookie use
SecureandHttpOnlywhere JavaScript access is not required? - Is
SameSiteexplicitly chosen for the application's real cross-site behavior? - Is
Domainomitted unless subdomain sharing is a deliberate requirement? - Is
Pathtreated as delivery scope rather than authorization? - Are expiration, rotation, logout, and revocation enforced on the server?
- Are CSRF and XSS defenses implemented separately?
- Are cookie deletion responses using the same name, Domain, and Path as the cookie being removed?
Conclusion
Cookie security is mostly an exercise in limiting automatic behavior. Secure limits the transport, HttpOnly limits script visibility, SameSite limits cross-site delivery, and a narrow host and path limit the intended audience. None of these attributes can tell the server whether a request is authorized or whether a stolen session is still legitimate.
For a small PHP application, the honest goal is not a magical cookie that solves web security. It is a deliberately scoped bearer of an opaque server-side session, surrounded by independent checks. That design may feel less impressive than adding another flag, but it gives each control a job that can actually be reviewed.
References
- RFC 6265: HTTP State Management Mechanism, IETF/RFC Editor.
- Cookies: HTTP State Management Mechanism, draft-ietf-httpbis-rfc6265bis-latest, IETF HTTP Working Group. This is a work in progress, not a final RFC.
- PHP: setcookie, PHP Documentation Group.
- Set-Cookie header, MDN Web Docs.
- Using HTTP cookies, MDN Web Docs.
- Session Management Cheat Sheet, OWASP Cheat Sheet Series.
