TLS Certificates, HTTPS, and Secure Cookies in Production

Explain what a TLS certificate is and how Let's Encrypt/Certbot automates issuing one, configure Nginx to redirect HTTP to HTTPS and enable HSTS safely, and correctly set the Secure and SameSite flags on cookies.

HTTPS is no longer optional for a production PHP application — browsers actively warn visitors away from plain HTTP sites, many modern web platform features (service workers, certain cookie behaviors, geolocation) are restricted to secure contexts, and any site handling logins or payments that isn't encrypted in transit is trivially interceptable on shared networks. The good news is that getting real, trusted TLS in production has become close to free and close to automatic thanks to Let's Encrypt. This lesson covers what a certificate actually is, how to obtain and auto-renew one, how to force all traffic onto HTTPS, how to mark cookies as HTTPS-only, and HSTS — a header that's extremely effective but genuinely dangerous to misconfigure, and exactly how to roll it out without locking yourself out of your own domain.

What a TLS Certificate Actually Is

A TLS certificate is a small signed document that binds a public key to a domain name, issued by a Certificate Authority (CA) that browsers and operating systems have pre-agreed to trust. When a browser connects to https://example.com, the server presents its certificate; the browser checks that the certificate's domain matches the one requested, that it hasn't expired, and — critically — that it's been signed by a CA whose own certificate is in the browser's trusted root store, forming a 'chain of trust' from the site's certificate up to a small number of globally trusted root CAs. If any of these checks fail, the browser shows the (correctly alarming) invalid-certificate warning page.

Historically, obtaining a certificate from a CA meant a manual, sometimes paid process: generate a certificate signing request (CSR), submit it to a CA, prove you control the domain (often by email or a DNS record), wait, then install the issued certificate file on your server. Let's Encrypt, a nonprofit CA launched in 2015, changed this by offering free certificates issued entirely through an automated protocol called ACME (Automated Certificate Management Environment) — domain validation, issuance, installation, and renewal can all happen with zero manual steps once configured.

Certbot is the most widely used ACME client for obtaining and installing Let's Encrypt certificates. certbot --nginx -d example.com -d www.example.com (or the Apache-specific plugin) will prove domain ownership (typically via an HTTP challenge — briefly serving a specific file at a well-known path that only the real domain owner's server could serve), obtain the certificate, and — with the web-server plugin — automatically edit the Nginx or Apache configuration to enable HTTPS on that vhost.

• A certificate binds a public key to a domain name, signed by a trusted Certificate Authority. • Browsers verify domain match, expiry, and a chain of trust up to a pre-installed root CA. • Let's Encrypt: a free CA using the ACME protocol for fully automated issuance and renewal. • Certbot: the standard ACME client; its Nginx/Apache plugins can also edit the web server config directly.

Redirecting HTTP to HTTPS

Once TLS is configured, plain HTTP requests should not be served at all — they should be redirected to the HTTPS equivalent of the same URL, with an HTTP 301 (permanent redirect) so browsers, and search engines, learn to go straight to HTTPS in the future. This is done with a separate server block listening on port 80 for the domain, whose only job is to issue the redirect; the actual site content lives in the port-443 block with the certificate configured.

It's important that the redirect preserves the full requested path and query string (return 301 https://$host$request_uri; in Nginx) rather than always redirecting to the bare domain root — a visitor following a specific link (say, a shared product page or a bookmarked deep link) over HTTP should land on that same page over HTTPS, not be dropped at the homepage.

A common ordering mistake is putting the redirect logic inside the same server block as the HTTPS content, guarded by an if on $scheme — Nginx's own documentation advises against using if for this and recommends the two-separate-server-blocks approach instead, both because it's clearer and because Nginx's if directive has well-documented surprising behaviors in some contexts ('if is evil' is a common phrase in Nginx configuration circles).

Secure Cookies: the Secure and SameSite Flags

A cookie's Secure attribute tells the browser to only ever send that cookie over an HTTPS connection — if the same site is somehow reached over plain HTTP (a stale link, a misconfigured redirect, a network downgrade attack), a Secure cookie is simply withheld rather than sent in the clear, which matters enormously for session and authentication cookies specifically, since those are exactly what an attacker on the same network wants to intercept. In PHP, this is set either via session_set_cookie_params() before starting a session, or per-cookie with the options array form of setcookie().

The SameSite attribute controls whether a cookie is sent along with cross-site requests — SameSite=Strict never sends the cookie on a cross-site navigation, SameSite=Lax (the modern browser default when unset) sends it on top-level cross-site GET navigations but not on cross-site subresource requests or POSTs, and SameSite=None sends it in all cases but requires the Secure attribute to also be set, or browsers will reject the cookie outright. SameSite=Lax or Strict on session cookies is one of the more effective, low-effort defenses against cross-site request forgery (CSRF), because it denies attacker-controlled cross-site pages the ability to have the victim's authenticated session cookie ride along with a forged request.

Getting cookie flags right specifically matters at the exact moment a site moves from HTTP to HTTPS: if session cookie settings are only added after the migration rather than during it, there is a window where cookies set before the change may lack the Secure flag, and old client-side cookies or caches can cause confusing mixed-security-state bugs — it's best to set Secure, HttpOnly, and an explicit SameSite value on session cookies from the very first deploy of an HTTPS-enabled site, not as an afterthought.

• Secure — cookie is only ever transmitted over HTTPS; essential for session/auth cookies. • HttpOnly (a related, separate flag) — cookie is inaccessible to JavaScript, mitigating XSS-based cookie theft. • SameSite=Strict/Lax — restricts cross-site sending of the cookie; Lax or Strict is a strong, cheap CSRF mitigation. • SameSite=None requires Secure — browsers reject a None cookie without it.

HSTS: Power and Risk

The Strict-Transport-Security (HSTS) response header tells a browser: 'for the next N seconds, never attempt to reach this domain over plain HTTP again — always rewrite it to HTTPS internally, before even making the network request.' This is a meaningfully stronger guarantee than a server-side redirect, because it protects against a specific real attack: an attacker on the same network intercepting the very first HTTP request (before any redirect can happen) and serving a fake response — HSTS means the browser never sends that first HTTP request at all for the header's lifetime, once it has seen the header once.

The risk is entirely in the max-age directive, measured in seconds. If a site sets a long max-age (a year is the commonly recommended production value, max-age=31536000) and then, for any reason — TLS certificate failure, a change of hosting, a subdomain that genuinely needs plain HTTP — needs to serve HTTP again, every browser that previously saw that header will flatly refuse to connect over HTTP for the remainder of that year, with no way for the site operator to undo it faster than that stored expiry, because the browser enforces it locally without checking back with the server at all. Adding includeSubDomains widens this to every subdomain, and preload (submitting the domain to browsers' built-in HSTS preload lists) makes the commitment apply even on a visitor's very first-ever visit, before they've received the header even once — and removal from preload lists can take months to propagate.

The safe rollout pattern is to start small and increase deliberately: begin with a short max-age (a few minutes to an hour) once HTTPS is confirmed fully working across every subdomain and use case the site has, monitor for any HTTP-dependent traffic or subdomains that break, and only then progressively raise max-age toward the standard one-year value over subsequent deploys — never jump straight to max-age=31536000; includeSubDomains; preload on a domain's first HTTPS deploy, because that forecloses any easy way back if something about the HTTPS setup turns out to be broken for some subset of traffic.

• HSTS makes the browser rewrite all future requests to HTTPS internally, without even attempting HTTP. • The commitment is stored client-side for max-age seconds and cannot be revoked early by the server. • includeSubDomains extends the rule to every subdomain; preload extends it to a visitor's very first visit ever. • Roll out HSTS with a short max-age first, verify nothing breaks, then increase toward the standard one-year value.

Certificate Renewal and Monitoring Expiry

Let's Encrypt certificates are deliberately short-lived — 90 days — specifically to encourage automated renewal over manual, easily-forgotten processes; a certificate that's automatically renewed every 60 days (well before the 90-day expiry) fails far less often in practice than one a human is expected to remember to renew annually. Certbot installs a systemd timer or cron job (certbot renew) during setup that checks all managed certificates and renews any nearing expiry, requiring no ongoing manual intervention once it's working.

Even with automated renewal, it's worth actively monitoring certificate expiry independently of trusting the renewal job blindly — a DNS change, a firewall rule, or a broken ACME HTTP challenge path can silently cause renewal to fail for weeks before anyone notices, right up until the certificate actually expires and the site starts throwing browser warnings to every visitor. External uptime/certificate-monitoring services, or a simple scheduled script that checks openssl s_client output against a threshold and alerts if a certificate is within, say, 14 days of expiry, catches this class of failure long before it becomes a production incident.

It's also good practice to test certbot renew --dry-run after any change to the web server configuration that Certbot's renewal process depends on (such as the ACME challenge location or the web server plugin's assumptions about vhost structure), since a config change that silently breaks the renewal path is exactly the kind of failure that goes unnoticed until the certificate actually expires.

Nginx: HTTP-to-HTTPS Redirect Plus TLS and HSTS

The port-80 block's only job is the redirect; it holds no application content. max-age=300 (5 minutes) is a deliberately short starting value for a first HSTS rollout — raise it gradually after confirming HTTPS works everywhere.

server {
    listen 80;
    server_name example.com www.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com www.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    add_header Strict-Transport-Security "max-age=300" always;

    root /var/www/myapp/public;
    # ... rest of the PHP-FPM config ...
}

Code Examples

The --nginx plugin both obtains the certificate via the ACME HTTP challenge and edits the Nginx server block to reference it; the installed systemd timer then renews automatically well before the 90-day expiry, and --dry-run is the safe way to verify the renewal path works after any related config change.

Setting secure, httponly, and samesite together before session_start() ensures the session cookie is never sent over plain HTTP, never readable by injected JavaScript, and largely immune to being replayed by a forged cross-site request — three independent, complementary protections on the single most sensitive cookie most PHP apps issue.

Common mistake: Setting 'secure' => true on a site that isn't actually served over HTTPS yet means the browser will never send the cookie at all — sessions will appear to silently fail to persist.

# Install Certbot and the Nginx plugin (Debian/Ubuntu)
sudo apt install certbot python3-certbot-nginx

# Obtain and install a certificate, editing the Nginx config automatically
sudo certbot --nginx -d example.com -d www.example.com

# Certbot installs a systemd timer automatically; verify it exists
systemctl list-timers | grep certbot

# Test that renewal would succeed without actually renewing
sudo certbot renew --dry-run
<?php
session_set_cookie_params([
    'lifetime' => 0,
    'path' => '/',
    'domain' => '',
    'secure' => true,       // only sent over HTTPS
    'httponly' => true,     // inaccessible to JavaScript
    'samesite' => 'Lax',    // sent on top-level navigation, not cross-site subresources/POSTs
]);
session_start();

HSTS Rollout Timeline

Each step is only taken after confirming the previous max-age caused no HTTP-dependent breakage.

Deploy 1              Deploy 2              Deploy 3 (steady state)
  max-age=300     →     max-age=86400    →     max-age=31536000; includeSubDomains
  (5 min, safe          (1 day, confirm        (1 year — only after full
   to reverse)           subdomains too)        confidence across the site)

Important Rules

Redirect all HTTP traffic to HTTPS with a dedicated port-80 server block issuing a 301, preserving the full path and query string.

Always set Secure, HttpOnly, and an explicit SameSite value on session and authentication cookies.

Never set a long HSTS max-age (or includeSubDomains/preload) on a domain's first HTTPS deploy — start short and increase gradually.

Automate certificate renewal (Certbot's timer) and independently monitor certificate expiry rather than trusting renewal blindly.

Run certbot renew --dry-run after any web server config change that touches the ACME challenge path.

Let's Encrypt's Impact on the Web

Before Let's Encrypt launched in 2015, the cost and manual effort of obtaining a certificate meant a large share of the web ran on plain HTTP by default; free, fully automated ACME issuance is a major reason HTTPS adoption crossed from a minority to the overwhelming majority of web traffic within a few years.

add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always; # on day one of HTTPS add_header Strict-Transport-Security "max-age=300" always; # start short, raise gradually over subsequent deploys

A long max-age set before HTTPS is fully verified across every subdomain and use case can lock browsers out of ever reaching the domain over HTTP for up to a year, with no way for the server to revoke that commitment early.

setcookie('session', $id, ['samesite' => 'None']); // Secure omitted setcookie('session', $id, ['samesite' => 'None', 'secure' => true]);

Modern browsers reject a cookie with SameSite=None that doesn't also set Secure — the cookie will simply fail to be stored, silently breaking whatever depended on it.

What real-world attack does HSTS protect against that a server-side 301 redirect alone does not? A. SQL injection against the login form B. An attacker on the same network intercepting the very first plain-HTTP request before any redirect can occur C. A certificate expiring without renewal D. Cross-site scripting via an uploaded file

B. An attacker on the same network intercepting the very first plain-HTTP request before any redirect can occur — A server-side redirect still requires the browser to send one initial HTTP request, which an on-path attacker can intercept; HSTS makes the browser rewrite the URL to HTTPS internally before ever sending that first request, once the header has been seen and its max-age hasn't expired.

Why is Let's Encrypt's 90-day certificate lifetime combined with automated renewal considered safer than a longer, manually-renewed certificate? A. Shorter certificates are cheaper B. It forces automation, which fails far less often in practice than a human remembering an annual manual renewal C. Browsers reject any certificate longer than 90 days D. It has no practical benefit, only a marketing one

B. It forces automation, which fails far less often in practice than a human remembering an annual manual renewal — A short lifetime is a deliberate design choice to make automated renewal the only practical option, since automated renewal jobs that run every 60 days fail far less often in aggregate than relying on a human to remember a yearly manual renewal.

A cookie is set with SameSite=None but no Secure flag. What happens in a modern browser? A. The cookie is sent only over HTTP B. The cookie is accepted normally with no restriction C. The browser rejects the cookie outright, since SameSite=None requires Secure D. The browser silently converts it to SameSite=Lax

C. The browser rejects the cookie outright, since SameSite=None requires Secure — Modern browsers require Secure whenever SameSite=None is set; a None cookie without Secure is rejected and never stored, which commonly shows up as a cookie that mysteriously never persists.

Write the two Nginx server blocks needed for example.com: one on port 80 that redirects everything to HTTPS preserving the path, and one on port 443 with TLS certificate paths and a conservative starting HSTS header of max-age=600.

server { listen 80; server_name example.com; return 301 https://$host$request_uri; } server { listen 443 ssl http2; server_name example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; add_header Strict-Transport-Security "max-age=600" always; }

Use return 301 https://$host$request_uri; in the port-80 block, and add_header Strict-Transport-Security in the port-443 block.