TLS Configuration That Holds Up

Enabling HTTPS is the easy part; the configuration, the certificate lifecycle and the downgrade paths are where it goes wrong.

TLS is table stakes, and "we have a certificate" is not a configuration review. Three things go wrong: weak settings, expiry, and paths that never reach TLS at all.

Protocols and ciphers

TLS 1.2 and 1.3 only. TLS 1.0 and 1.1 are deprecated and fail most compliance scans. On 1.3 let the protocol negotiate; on 1.2 restrict to AEAD suites with forward secrecy — ECDHE key exchange with AES-GCM or ChaCha20-Poly1305.

Certificates

Automate issuance and renewal. Manual renewal is how APIs go down on a bank holiday. Alert at 30 days, not on the day. Support two certificates during rotation so a mismatch is not an outage, and monitor certificate transparency logs for certificates issued for your domains that you did not request.

The downgrade paths

An attacker rarely breaks TLS; they stop it starting. The first request to http:// carries cookies in the clear before any redirect arrives.

  • HSTS tells the browser never to use HTTP for this host again. Consider the preload list once you are confident.
  • The Secure cookie attribute means the browser will not send it over HTTP at all.
  • Redirect HTTP to HTTPS — and remember the redirect itself is the unprotected request.

Behind a terminating proxy

TLS usually ends at a load balancer, and your application sees plain HTTP. Configure trusted proxies so X-Forwarded-Proto is believed, or the framework will refuse to set Secure cookies and will build http:// redirect URLs. Encrypt the internal hop too — "the private network" is an assumption, not a control.

Verify from outside

Your configuration file describes intent. A scan describes reality, including the CDN and load balancer in front of you that you did not configure.

Example

Example · bash
# What is actually served, not what the config file says
docker run --rm drwetter/testssl.sh --quiet --severity MEDIUM https://dfg.com

# Protocol support, one at a time
for v in tls1 tls1_1 tls1_2 tls1_3; do
  printf '%-8s ' "$v"
  openssl s_client -connect dfg.com:443 -$v </dev/null 2>&1 \
    | grep -q 'Cipher is (NONE)' && echo 'refused ✅' || echo 'ACCEPTED'
done

# Certificate dates and issuer
openssl s_client -connect dfg.com:443 -servername dfg.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -dates

# HSTS present?
curl -sI https://dfg.com | grep -i strict-transport-security

When to use it

  • An expired certificate takes an API down on a public holiday until issuance is automated with 30-day alerting.
  • A scan reveals TLS 1.0 still enabled on a legacy load balancer that the application configuration never mentioned.
  • Secure cookies stop being set in production because the app was not configured to trust X-Forwarded-Proto from its load balancer.

More examples

A configuration that passes a scan

The deploy-hook reload is the step that turns automated renewal into automated deployment — certbot renews happily while nginx continues serving the old certificate.

Example · bash
# nginx
server {
    listen 443 ssl;
    http2 on;
    server_name dfg.com;

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

    # Protocols: 1.2 and 1.3 only
    ssl_protocols TLSv1.2 TLSv1.3;

    # On 1.3 the protocol chooses; on 1.2 restrict to AEAD + forward secrecy
    ssl_prefer_server_ciphers off;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:\
ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:\
ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;

    ssl_ecdh_curve X25519:prime256v1:secp384r1;

    # Session handling
    ssl_session_timeout 1d;
    ssl_session_cache shared:SSL:10m;
    ssl_session_tickets off;        # poorly rotated ticket keys weaken PFS

    # OCSP stapling — the client does not have to contact the CA
    ssl_stapling on;
    ssl_stapling_verify on;
    resolver 1.1.1.1 8.8.8.8 valid=300s;

    add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
}

# Redirect HTTP — and note this request itself is unprotected, which is
# exactly what HSTS exists to eliminate on subsequent visits.
server {
    listen 80;
    server_name dfg.com;
    return 308 https://$host$request_uri;
}

# ── Automated issuance ───────────────────────────────────────────────
# certbot renew runs twice daily; the reload is the part people forget
certbot renew --deploy-hook "nginx -s reload"

# ── Expiry alerting, 30 days out ─────────────────────────────────────
# scripts/cert-expiry-check.sh
for host in dfg.com api.dfg.com auth.dfg.com; do
  end=$(openssl s_client -connect "$host:443" -servername "$host" </dev/null 2>/dev/null \
        | openssl x509 -noout -enddate | cut -d= -f2)
  days=$(( ( $(date -d "$end" +%s) - $(date +%s) ) / 86400 ))
  echo "$host: $days days"
  [ "$days" -lt 30 ] && alert "Certificate for $host expires in $days days"
done

Trusting the proxy, in three frameworks

Specifying the proxy CIDR rather than a hop count is the stronger form: it works correctly even when the number of hops changes.

Example · javascript
// TLS terminates at the load balancer, so the app sees http:// and refuses to
// set Secure cookies. Every framework has this switch and it is easy to miss.

// ── Express ──────────────────────────────────────────────────────────
app.set('trust proxy', 1);          // exactly the number of proxies you run
// Now req.secure and req.ip are correct behind the load balancer.

app.use((req, res, next) => {
  if (!req.secure) {
    return res.redirect(308, `https://${req.headers.host}${req.originalUrl}`);
  }
  res.set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
  next();
});

// ⚠️ NEVER `trust proxy: true` on a publicly reachable app — the client then
//    forges X-Forwarded-For and X-Forwarded-Proto itself.

/* ── Laravel ───────────────────────────────────────────────────────────
   // bootstrap/app.php
   ->withMiddleware(function (Middleware $middleware) {
       $middleware->trustProxies(at: ['10.0.0.0/8'], headers:
           Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST |
           Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO);
   })

   // AppServiceProvider::boot()
   if (app()->environment('production')) { URL::forceScheme('https'); }
*/

/* ── Django ────────────────────────────────────────────────────────────
   SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
   SECURE_SSL_REDIRECT = True
   SECURE_HSTS_SECONDS = 31536000
   SECURE_HSTS_INCLUDE_SUBDOMAINS = True
   SESSION_COOKIE_SECURE = True
   CSRF_COOKIE_SECURE = True
*/

// ── Verify it once, in production ────────────────────────────────────
app.get('/debug/proto', (req, res) => res.json({
  secure: req.secure,                         // must be true
  protocol: req.protocol,                     // must be 'https'
  ip: req.ip,                                 // must be the CLIENT ip
  xForwardedProto: req.get('x-forwarded-proto'),
  xForwardedFor: req.get('x-forwarded-for'),
}));
// If secure is false behind TLS, Secure cookies are silently dropped and the
// HTTPS redirect loops. Check once, then remove this route.

Certificate transparency monitoring

CAA records are underused: they are three DNS entries and they prevent mis-issuance rather than merely detecting it after the fact.

Example · bash
# Every certificate issued for your domains appears in a public log. Watching
# it catches two things: expiry you missed, and certificates you did not request.

# ── What exists for your domains right now ───────────────────────────
curl -s 'https://crt.sh/?q=%25.dfg.com&output=json' \
  | jq -r '.[] | "\(.not_after)  \(.issuer_name | split(", ") | .[-1])  \(.name_value)"' \
  | sort -u | tail -20

# Look for:
#   - hostnames you did not know existed        → see the inventory lesson
#   - an issuer you do not use                  → possible mis-issuance
#   - certificates issued outside your pipeline → someone bypassed the process

# ── Automate it ──────────────────────────────────────────────────────
#!/usr/bin/env bash
KNOWN=known-certs.txt
CURRENT=$(mktemp)

curl -s 'https://crt.sh/?q=%25.dfg.com&output=json' \
  | jq -r '.[] | "\(.id) \(.name_value)"' | sort -u > "$CURRENT"

if [ -f "$KNOWN" ]; then
  NEW=$(comm -13 "$KNOWN" "$CURRENT")
  if [ -n "$NEW" ]; then
    echo "New certificates issued for dfg.com:"
    echo "$NEW"
    alert "CT log: new certificates for dfg.com" "$NEW"
  fi
fi
mv "$CURRENT" "$KNOWN"

# ── CAA records: restrict who MAY issue ──────────────────────────────
# DNS, and it is enforced by every compliant CA at issuance time.
dfg.com.  IN  CAA  0 issue "letsencrypt.org"
dfg.com.  IN  CAA  0 issuewild ";"                      # no wildcards
dfg.com.  IN  CAA  0 iodef "mailto:[email protected]"    # report attempts

# Verify
dig +short CAA dfg.com

# CAA is one DNS record and it stops another CA issuing for your domain at all,
# which is a stronger control than noticing afterwards in a CT log.

Discussion

  • Be the first to comment on this lesson.