Kokil Thapa - Professional Web Developer in Nepal
Freelancer Web Developer in Nepal with 15+ Years of Experience

Kokil Thapa is an experienced full-stack web developer focused on building fast, secure, and scalable web applications. He helps businesses and individuals create SEO-friendly, user-focused digital platforms designed for long-term growth.

Secure Session Cookies with SameSite and HttpOnly

By Kokil Thapa | Last reviewed: September 2026

Session hijacking still starts with a cookie the browser sends on every request. If that cookie lacks the right flags, a single XSS flaw or cross-site form can hand an attacker a live login. Secure session cookies with SameSite and HttpOnly are the baseline fix: they limit JavaScript access, block most cross-site sends, and (with Secure) refuse plain HTTP. This guide covers Laravel 13, raw PHP 8.5, nginx/Apache edge cases, and the production mistakes I see on Nepal-hosted Laravel and WordPress deployments.

What are secure session cookies with SameSite and HttpOnly?

A session cookie stores the server-side session ID. The browser attaches it automatically to matching requests. Without protection, any script on your page can read it. A malicious third-party site can also trigger sends in some browser versions.

Three attributes harden that cookie:

  • HttpOnly — JavaScript cannot read or write the cookie via document.cookie.
  • Secure — The browser sends the cookie only over HTTPS.
  • SameSite — Controls whether the cookie goes out on cross-site navigation and embedded requests.

HttpOnly does not stop XSS from performing actions as the user. It stops the attacker from exfiltrating the raw session ID. SameSite reduces CSRF risk by withholding the cookie on many cross-origin POST and iframe loads. Together they form the minimum bar for production authentication systems.

Session Cookie Attack SurfaceBrowserStores session IDYour AppValidates sessionAttackerXSS or CSRFHttpOnly + SameSite + SecureHttpOnlyBlocks JS theftSameSiteLimits cross-siteSecureHTTPS onlyCookie never exposed to scripts or HTTP
How Secure session cookies with SameSite and HttpOnly shrink the XSS and CSRF attack surface on session IDs.

How do you configure HttpOnly and SameSite in Laravel 13?

Laravel 13 centralises cookie flags in config/session.php. The framework reads these values when it issues the session cookie through Symfony's HttpFoundation component. Set them once and every login, logout, and page view inherits the same policy.

<?php
// config/session.php (Laravel 13.x)

return [
    'driver' => env('SESSION_DRIVER', 'database'),
    'lifetime' => (int) env('SESSION_LIFETIME', 120),
    'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
    'encrypt' => env('SESSION_ENCRYPT', true),
    'cookie' => env('SESSION_COOKIE', Str::slug(env('APP_NAME', 'laravel')).'-session'),
    'path' => env('SESSION_PATH', '/'),
    'domain' => env('SESSION_DOMAIN'),
    'secure' => env('SESSION_SECURE_COOKIE', true),
    'http_only' => env('SESSION_HTTP_ONLY', true),
    'same_site' => env('SESSION_SAME_SITE', 'lax'),
    'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

Match your .env to the environment:

SESSION_DRIVER=database
SESSION_LIFETIME=120
SESSION_ENCRYPT=true
SESSION_SECURE_COOKIE=true
SESSION_HTTP_ONLY=true
SESSION_SAME_SITE=lax
APP_URL=https://example.com

On local HTTP, set SESSION_SECURE_COOKIE=false temporarily. Never ship that to production. I've debugged "login works locally, fails on server" dozens of times. The cause was almost always Secure=true behind HTTP or a mis-set SESSION_DOMAIN.

For multi-server Laravel session setups, use Redis or database drivers. Cookie flags still apply at the edge. Each app node must share the same APP_KEY when SESSION_ENCRYPT=true.

Sanctum and SPA cookies

API-first apps using Sanctum for SPA auth need the same flags on the laravel_session and XSRF-TOKEN cookies. Sanctum reads config/session.php for the session cookie. The CSRF cookie is separate. Keep HttpOnly on the session cookie only. The XSRF token cookie must stay readable by JavaScript for Axios/fetch interceptors.

Never set HttpOnly on the XSRF-TOKEN cookie. That breaks SPA CSRF handling. Do set Secure and SameSite on both.

What does each SameSite value do for session security?

SameSite tells the browser when to include a cookie on cross-site requests. Modern Chrome, Firefox, and Safari default unset cookies to Lax. Explicit configuration beats relying on browser defaults across older clients.

SameSite valueTop-level GET navigationCross-site POST / iframeTypical use
StrictCookie withheld until same-site visitCookie withheldAdmin panels, banking, high-risk portals
LaxCookie sent (link from email, bookmark)Cookie withheld on POSTDefault for most Laravel apps and eCommerce
NoneCookie sent cross-siteCookie sentEmbedded widgets; requires Secure

Lax is the sweet spot for sites like client portals with document uploads and payments. Users arriving from Google or an email link stay logged in. Cross-site POST forgeries do not carry the session cookie.

Strict logs users out when they open your site from an external link. That surprises non-technical clients. Reserve it for admin-only zones behind a separate subdomain and cookie name.

None is rare in Laravel monoliths. You need it only when a third-party iframe or cross-origin API must send your session cookie. It always pairs with Secure. Browsers reject SameSite=None without Secure.

SameSite Request FlowSite Aevil.exampleSite Byourapp.comLax: GET link clickCookie SENTLax: cross-site POSTCookie BLOCKEDStrict: any cross-siteCookie BLOCKEDNone + SecureCookie SENT always
SameSite Lax allows top-level GET navigation but blocks most cross-site POST CSRF attempts.

How do you set secure session cookies in plain PHP 8.5?

Legacy apps and custom microservices outside Laravel still rely on PHP's native session handler. PHP 8.5 exposes the same ini keys. Set them in php.ini, a pool-specific FPM file, or at runtime before session_start().

php.ini or FPM pool snippet

; /etc/php/8.5/fpm/pool.d/www.conf or php.ini
session.cookie_httponly = 1
session.cookie_secure = 1
session.cookie_samesite = Lax
session.use_strict_mode = 1
session.use_only_cookies = 1
session.cookie_lifetime = 0
session.gc_maxlifetime = 7200

Runtime configuration before session_start()

<?php
declare(strict_types=1);

ini_set('session.cookie_httponly', '1');
ini_set('session.cookie_secure', '1');
ini_set('session.cookie_samesite', 'Lax');
ini_set('session.use_strict_mode', '1');

session_name('APPSESSID');
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => '.example.com',
    'secure' => true,
    'httponly' => true,
    'samesite' => 'Lax',
]);

session_start();

session.use_strict_mode rejects session IDs the server never created. That closes fixation attacks where an attacker seeds a known ID. Pair it with regeneration on login:

session_regenerate_id(true);

On shared hosting common in Nepal, you may lack full php.ini access. A .user.ini or .htaccess php_value directive can set the same keys. Confirm with phpinfo() in a staging slot first. Wrong FPM pool edits affect every vhost on that pool.

When should the Secure flag be used on session cookies?

Always enable Secure in production when your site is served over HTTPS. Let's Encrypt makes TLS free. There is no good reason to accept session cookies on port 80 in 2026.

The Secure flag is independent of HttpOnly and SameSite. All three appear on the same Set-Cookie header. A typical production header looks like this:

Set-Cookie: laravel_session=eyJpdiI6...; expires=...; Max-Age=7200;
  path=/; domain=.example.com; secure; httponly; samesite=lax

Watch these edge cases behind reverse proxies:

  1. TLS termination at nginx — Laravel must trust X-Forwarded-Proto: https or it generates HTTP URLs and may mis-detect secure context. Set TrustProxies middleware correctly.
  2. Mixed content during migration — During HTTP→HTTPS cutover, run a hard redirect before enabling Secure cookies site-wide.
  3. Staging on HTTP — Use a separate .env.staging with SESSION_SECURE_COOKIE=false. Document the difference so nobody copies staging env to production.
  4. Subdomain cookiesSESSION_DOMAIN=.example.com shares sessions across app and www. A typo like example.com. silently breaks logins.

For infrastructure work beyond app config, see Linux system administration for TLS and proxy headers. Cookie flags mean nothing if traffic still hits PHP over plain HTTP on the origin.

HTTPS Termination FlowBrowserHTTPS onlyNginxTLS terminatePHP-FPMLaravel 13Set-CookieSecure flagTrustProxies requiredX-Forwarded-Proto: httpsX-Forwarded-For: client IPSecure cookie issued only when app sees HTTPS
Reverse-proxy TLS termination: Laravel must trust forwarded proto headers or Secure session cookies fail to set correctly.

Do not guess from config files alone. Inspect the live Set-Cookie response header after deploy. Browser DevTools → Network → first document request → Response Headers shows the truth.

curl one-liner

curl -sI https://example.com/login \
  | grep -i set-cookie

Expect HttpOnly, Secure, and SameSite=Lax (or your chosen value) on the session cookie. If flags are missing, clear config cache:

php artisan config:clear
php artisan config:cache

Stale cached config is a common post-deploy surprise on production servers using config:cache in CI.

Checklist for QA

  • Log in on HTTPS. Confirm session cookie flags in DevTools Application tab.
  • Attempt document.cookie in the console. Session name must not appear when HttpOnly is on.
  • Submit a cross-origin POST form from a local HTML file. With SameSite=Lax, the action should fail auth unless CSRF token is present.
  • Log out. Confirm the cookie expires or is invalidated server-side.
  • Run strong password generation for test accounts. Never reuse production credentials in staging.

Automate header checks in CI where possible. A smoke test that asserts cookie attributes catches regressions before they reach clients. Pair this with guidance from OWASP Top 10 practices for Laravel and pre-release security testing.

What are common mistakes that break secure session cookies?

Misconfiguration often looks like a bug in the framework. It is not. These patterns show up repeatedly on client projects and sister-site deploy pipelines I maintain.

Setting HttpOnly globally via a middleware that wraps every outgoing cookie will break SPA CSRF flows. Scope HttpOnly to the session cookie only.

SameSite=None without Secure

Browsers silently drop the cookie. OAuth return flows and embedded dashboards fail with random logouts. Fix both flags together.

Overbroad domain attribute

domain=.co.np or a parent domain you do not control leaks cookies to sibling hosts. Keep the domain exact or use a single intentional wildcard like .yourbrand.com.

Long session lifetime without rotation

HttpOnly limits theft via JS. It does not help if the cookie itself lives 30 days on a shared computer. Keep SESSION_LIFETIME at 120 minutes for admin areas. Regenerate ID on privilege change.

WordPress and WooCommerce 11.1

WordPress sets its own auth cookies via wp-config.php constants and plugin filters. WooCommerce adds cart session cookies. Audit with the same DevTools method. Plugins that set JavaScript-readable "session" cookies defeat HttpOnly benefits. Review third-party chat and analytics scripts on document-heavy legal portals especially carefully.

For API-only backends that abandon cookies entirely, compare trade-offs in JWT vs session vs API key authentication. Cookies remain the right default for server-rendered Laravel Blade apps and enterprise web portals.

SameSite Decision TreeNeed session cookie?Cross-site iframe?Same-site app?YesNoSameSite=None+ Secure requiredSameSite=LaxDefault choiceSameSite=StrictAdmin onlyAlways add HttpOnly + Secure on HTTPS
Decision tree for picking SameSite Strict, Lax, or None when configuring secure session cookies.

External references worth bookmarking: the MDN Set-Cookie reference, the Laravel 13 session documentation, and OWASP Session Management Cheat Sheet. They stay aligned with browser behaviour better than random Stack Overflow answers from 2019.

Store production secrets outside git. Encrypted env patterns from Ansible Vault for secrets and API rate limiting complement cookie hardening. They do not replace it. Defense in depth means fixing XSS, enforcing CSRF tokens, and locking session storage. Cookie flags are layer one, not the whole wall.

If you run payment callbacks from eSewa, Khalti, or Stripe, confirm return URLs stay same-site or use token exchange instead of cross-site cookie reliance. I've seen payment success pages lose sessions when Strict was enabled without planning the redirect chain. Map the full redirect path before you flip SameSite to Strict on a live store like a grocery delivery platform.

Redis 8.10 as a session driver adds server-side expiry independent of cookie Max-Age. Stolen cookie bytes still work until the server entry dies. Short lifetimes plus session_regenerate_id() on login close that gap. For high-traffic apps, read about speed optimisation alongside security. A slow login page encourages password reuse and shared sessions on office kiosks — a human factor no flag fixes.

On projects I ship, cookie policy is part of the deployment checklist next to SSL and backup verification. Treat it that way on your stack too.

Key Takeaways

  • Enable HttpOnly, Secure (HTTPS), and SameSite=Lax on every production session cookie.
  • Laravel 13: set SESSION_HTTP_ONLY, SESSION_SECURE_COOKIE, and SESSION_SAME_SITE in .env and verify via curl or DevTools.
  • Keep the XSRF-TOKEN cookie JavaScript-readable; never apply HttpOnly to it in SPA setups.
  • Use SameSite=Strict only when you accept logout-on-external-link behaviour; default to Lax.
  • Trust reverse-proxy HTTPS headers correctly or Secure cookies will not issue on TLS-terminated hosts.
  • Test live response headers after every deploy — cached config silently drops flags.

People Also Ask

Does HttpOnly prevent XSS attacks?

No. HttpOnly stops JavaScript from reading the session cookie. An XSS attacker can still send authenticated requests from the victim's browser while the session is active. You must still escape output, use a Content Security Policy, and fix XSS sources. HttpOnly limits cookie exfiltration, not session abuse.

Is SameSite=Lax enough for CSRF protection?

It helps significantly but is not a full substitute for CSRF tokens. SameSite blocks many cross-site POST cookie sends. Same-site subdomains, GET-changing routes, and older browsers still need Laravel's @csrf middleware and form tokens. Use both.

Can I use secure session cookies on localhost?

Browsers treat http://localhost as a secure context in many cases, but Secure cookies still expect HTTPS on real domains. For local HTTP dev, disable SESSION_SECURE_COOKIE. Use mkcert or Laravel Valet with HTTPS locally if you want parity with production flags.

What is the difference between session cookies and permanent cookies?

Session cookies expire when the browser closes (Max-Age=0 or no expiry). Persistent cookies carry an explicit expiry date. Laravel's session cookie respects SESSION_LIFETIME in minutes. Auth "remember me" tokens are separate cookies and need the same HttpOnly, Secure, and SameSite treatment.

Ship secure sessions on your next deploy

Secure session cookies with SameSite and HttpOnly take thirty minutes to configure and years of headache to skip. Set the flags in Laravel or PHP, verify headers on staging, and add a CI smoke check before production. If you want a second pair of eyes on auth, proxy headers, or a legal-tech portal that handles sensitive documents, reach out for a security-focused review or browse web development services for full-stack delivery.

Frequently Asked Questions

Session cookies carry the server-side session ID on every matching request. HttpOnly blocks JavaScript from reading them via document.cookie. Secure sends them only over HTTPS. SameSite controls cross-site sends. Together they are the minimum bar for production authentication.

Laravel 13 centralises flags in config/session.php, read when Symfony HttpFoundation issues the session cookie. Set secure, http_only, and same_site there, then mirror them in .env: SESSION_SECURE_COOKIE=true, SESSION_HTTP_ONLY=true, and SESSION_SAME_SITE=lax. Match APP_URL to HTTPS in production. For local HTTP, set SESSION_SECURE_COOKIE=false temporarily and never ship that to production. I've debugged many "login works locally, fails on server" cases caused by Secure=true behind HTTP or a wrong SESSION_DOMAIN. Clear config cache after deploy if flags look stale.

SameSite tells the browser when to attach the cookie on cross-site requests. Strict withholds it until a same-site visit, so users opening your site from an external link appear logged out. Lax sends it on top-level GET navigation but blocks most cross-site POST and iframe loads — the default sweet spot for client portals, eCommerce, and sites where users arrive from Google or email. None sends the cookie cross-site and requires Secure; browsers reject None without it. Use None only when a third-party iframe or cross-origin API must send your session cookie.

Set session.cookie_httponly, session.cookie_secure, and session.cookie_samesite in php.ini, an FPM pool file such as /etc/php/8.5/fpm/pool.d/www.conf, or at runtime before session_start(). Enable session.use_strict_mode and session.use_only_cookies to reject unknown session IDs and block fixation. Call session_regenerate_id(true) on login. On shared hosting common in Nepal, a .user.ini or .htaccess php_value directive may work when full php.ini access is unavailable — confirm with phpinfo() in staging first, because wrong FPM pool edits affect every vhost on that pool.

Always in production when the site runs over HTTPS. Let's Encrypt makes TLS free, so there is no good reason to accept session cookies on plain HTTP in 2026.

Do not trust config files alone — inspect the live Set-Cookie header after deploy. In DevTools, open Network, select the first document request, and check Response Headers. Or run curl -sI https://example.com/login and grep for set-cookie; expect HttpOnly, Secure, and SameSite=Lax on the session cookie. If flags are missing after a deploy, run php artisan config:clear and php artisan config:cache because stale cached config is a common production surprise. In the console, document.cookie must not list the session name when HttpOnly is on. Test cross-origin POST from a local HTML file to confirm Lax blocks unauthenticated forgery.

Applying HttpOnly globally via middleware that wraps every outgoing cookie breaks SPA CSRF — scope HttpOnly to the session cookie only. SameSite=None without Secure causes browsers to silently drop the cookie, breaking OAuth returns and embedded dashboards. An overbroad domain like .co.np or a parent domain you do not control leaks cookies to sibling hosts. Long SESSION_Lifetime without ID rotation leaves stolen cookies valid for days. Mis-set SESSION_DOMAIN, such as example.com. instead of .example.com, silently breaks logins across subdomains. WordPress and WooCommerce plugins that set JavaScript-readable session cookies defeat HttpOnly — audit third-party chat and analytics on document-heavy portals.

No. HttpOnly stops JavaScript from reading the session cookie; it does not stop an XSS attacker from sending authenticated requests while the session is active.

It helps significantly but is not a full substitute for CSRF tokens. SameSite=Lax blocks many cross-site POST cookie sends, which closes a large CSRF window. Gaps remain: same-site subdomains, GET routes that change state, and older browsers still need Laravel @csrf middleware and form tokens. On production Laravel apps I treat cookie flags and CSRF tokens as complementary layers — SameSite reduces accidental cross-site sends, while tokens prove intent on requests that still carry the cookie. Payment callbacks from eSewa, Khalti, or Stripe also need their redirect chains mapped before you assume Lax covers every flow.

Browsers treat http://localhost as a secure context in many cases, but Secure cookies on real domains expect HTTPS. For local HTTP development, set SESSION_SECURE_COOKIE=false in .env and keep HttpOnly and SameSite=Lax enabled. Use a separate .env.staging documented clearly so nobody copies HTTP-only settings into production. Staging on plain HTTP follows the same rule: disable Secure temporarily, verify flags in DevTools, then re-enable Secure before the HTTPS cutover. During HTTP-to-HTTPS migration on a live domain, run a hard redirect before enabling Secure cookies site-wide to avoid mixed-content logout loops.

API-first apps using Sanctum for SPA auth need the same Secure and SameSite flags on both laravel_session and XSRF-TOKEN. Sanctum reads config/session.php for the session cookie. Keep HttpOnly on the session cookie only. The XSRF-TOKEN cookie must stay readable by JavaScript so Axios or fetch interceptors can read it and send X-XSRF-TOKEN headers. Never set HttpOnly on XSRF-TOKEN — that breaks SPA CSRF handling. Do set Secure and SameSite on both cookies. This split is a recurring production mistake when teams apply a global HttpOnly middleware without scoping it to session cookies alone.

When TLS terminates at nginx or another reverse proxy, Laravel must trust X-Forwarded-Proto: https via TrustProxies middleware. Without that, the app thinks requests are HTTP, may generate HTTP URLs, and mis-detects secure context — so Secure cookies fail to set or logins break after deploy. Cookie flags mean nothing if traffic still reaches PHP over plain HTTP on the origin behind the proxy. I've seen this repeatedly on Nepal-hosted Laravel deployments where SSL works in the browser but the app layer still sees http. Fix proxy header trust first, then confirm Set-Cookie includes Secure via curl or DevTools on the public HTTPS URL.

Reserve Strict for admin panels, banking, or high-risk portals where you accept logout-on-external-link behaviour. Strict withholds the cookie until the user visits your site directly, so opening a bookmark or email link shows them logged out. Lax is the default for most Laravel apps, client portals with document uploads, and eCommerce because users arriving from Google or email stay logged in. Before enabling Strict on a live store or payment flow, map the full redirect path — I've seen payment success pages lose sessions when Strict was enabled without planning returns from eSewa, Khalti, or Stripe. Consider Strict only on a separate admin subdomain with its own cookie name.

Modern browsers silently reject or drop the cookie. OAuth return flows, embedded dashboards, and third-party iframe integrations then fail with random logouts that look like framework bugs. SameSite=None is rare in Laravel monoliths and is needed only when a cross-origin iframe or API must send your session cookie. It always pairs with Secure — browsers refuse None without it. Fix both flags together in config/session.php and .env, then verify the live Set-Cookie header. Teams often set None for a payment or embed integration, forget Secure on staging HTTP, and spend hours debugging Sanctum or WooCommerce auth instead of reading the dropped cookie in DevTools.

WordPress sets its own auth cookies via wp-config.php constants and plugin filters; WooCommerce 11.1 adds cart session cookies separate from Laravel's config/session.php. The same HttpOnly, Secure, and SameSite principles apply, but you audit with DevTools on the live response headers rather than Laravel .env keys. Plugins that set JavaScript-readable session or cart cookies defeat HttpOnly benefits — review third-party chat, analytics, and marketing scripts especially on document-heavy legal portals. Cookie hardening on WordPress is still layer one: fix XSS sources, enforce CSRF on forms, and confirm return URLs from payment gateways stay same-site or use token exchange instead of relying on cross-site cookie sends.

Share this article

0 Comments

Leave a comment

Your email is not published. Comments appear once they have been read. Sign in to have your details filled in.

Quick Contact Options
Choose how you want to connect me: