Skip to main content

Heroku Killed 12 TLS Ciphers: The Legacy-Client Audit Your Self-Hosted PaaS Needs Too

9 min readDora NodaDora Noda
Share
On this page

On August 24, 2026, Heroku changed the TLS handshake for every app running on its Common Runtime. Twelve older cipher suites disappeared overnight, TLS 1.3 and HTTP/2 arrived, and the cipher suites for default *.herokuapp.com domains and custom domains were aligned into one curated set. If you host on Heroku, your oldest clients may have stopped connecting that day with an error message that looks like a network blip, not a policy change. And if you run your own platform, here is the uncomfortable part: those same twelve ciphers are probably still enabled on your gateway right now.

This post turns Heroku's change into something you can run tonight: what exactly changed, which clients it breaks, a reproducible openssl / sslyze / testssl.sh audit for your own domains, and the concrete cert-manager plus Gateway API baseline to converge on.

What Heroku actually changed

Heroku's SSL documentation has always stated the posture plainly: TLS 1.2 and TLS 1.3 are supported, SSLv3 and TLS 1.0/1.1 are refused for security reasons, and Heroku curates the cipher suites to balance best practice against backward compatibility. The August 2026 change moved the slider decisively toward best practice:

Before August 24, 2026After
Minimum protocolTLS 1.2 (1.0/1.1 refused for years)TLS 1.2
TLS 1.3Partial availabilityEnabled across Common Runtime endpoints
HTTP/2Opt-in, limited to custom domainsAvailable broadly (check protocol=http2.0 in router logs)
Cipher suitesBroad compatibility set, including pre-AEAD legacy suitesCurated AEAD-only set with forward secrecy
Default vs custom domainsDifferent suite listsAligned into one set

Two details matter for self-hosters. First, Heroku's router logs expose tls_version=tls1.3 and protocol=http2.0 per request, which is exactly the per-connection visibility you need to find legacy clients before they break — more on that below. Second, and most important: Heroku does not let Common Runtime tenants customize TLS versions or ciphers at all. Only Private Spaces get adjustable cipher suites. On the managed platform, the operator makes the call and every tenant inherits it. On your own platform, you are that operator — and inheriting a decision requires someone to actually make it.

The twelve dead ciphers: what they were and what killed them

Heroku did not publish a eulogy for each suite, but twelve-suite culls all come from the same pre-AEAD era. Every legacy suite belongs to one of four families, and each family has a named attack (or three) that makes it unfixable rather than merely unfashionable:

FamilyExample suitesWhat killed itWhy it cannot be saved
RC4 stream cipherRC4-SHA, ECDHE-RSA-RC4-SHAKeystream biases (RFC 7465 prohibits RC4 in TLS, 2015)Statistical flaws are in the cipher itself; no mode change helps
3DES (64-bit blocks)DES-CBC3-SHASWEET32 birthday attacks on 64-bit blocks (2016)64-bit block size caps safe data per key at ~32 GB — a busy connection exhausts that
CBC-mode with static RSA key exchangeAES128-SHA, AES256-SHA, DES-CBC3-SHABEAST (2011), Lucky 13 timing oracles (2013), ROBOT oracle (2017)No forward secrecy: one stolen private key decrypts all past traffic, and padding oracles keep resurfacing
Export / anonymous / NULLEXP-*-*, ADH-*, NULL-*Deliberately breakable by design (1990s export law) or unauthenticatedNo legitimate client needs them; scanners flag them as critical on sight

The pattern is worth naming: TLS 1.3 did not negotiate with any of these families — it removed RC4, 3DES, CBC, static RSA, and compression from the spec entirely, leaving exactly five AEAD suites. Heroku's cull brings its TLS 1.2 surface into line with what TLS 1.3 already decided. If your gateway still negotiates anything in the left column, you are running Heroku's pre-August posture, and every scanner your customers' security teams run will keep telling them so.


Who breaks: the legacy-client matrix

Dropping ciphers never breaks modern clients — every maintained browser, language runtime, and HTTP library has spoken ECDHE + AES-GCM for a decade. It breaks the long tail of unmaintained software that only offers the old suites, and that tail clusters in a few recognizable places:

ClientWhy it fails after the cullWhat to do
Java 6, or Java 7 with defaultsNo TLS 1.2 by default (Java 7 needs -Dhttps.protocols=TLSv1.2)Upgrade the runtime; as a stopgap, set the protocol flag explicitly
.NET Framework before 4.7Defaults to TLS 1.0/1.1 unless ServicePointManager.SecurityProtocol sets TLS 1.2Set TLS 1.2 in code or registry; upgrade to 4.7+ where 1.2 is the default
Android 4.3 and olderTLS 1.2 missing (4.1–4.3) or disabled by default (4.4); only legacy suites offeredDrop support deliberately or route through an updater; do not re-enable 3DES server-side for them
Python 2.7 with system OpenSSL 0.9.8/1.0.0No TLS 1.2 support at allUpgrade interpreter and OpenSSL; this stack is end-of-life twice over
curl / wget / scripts on OpenSSL before 1.0.1TLS 1.0-only handshakesRebuild against a maintained OpenSSL
Embedded devices and IoT firmware with hardcoded suite listsHandshake failure with no useful error — the device just goes darkFirmware update; give vendors a test endpoint ahead of the cutoff

Note what is not on this list: PCI DSS 4.0 already prohibited TLS 1.0/1.1 for cardholder-data environments as of March 2025, so anything handling payments should have cleared this bar a year ago. The remaining breakage surface is internal tooling, cron scripts nobody remembers writing, and enterprise clients with a six-year-old integration.

Before you change anything server-side, find out whether any of these clients talk to you. You do not need a scanner for this — you need per-connection telemetry. Heroku's router logs already record tls_version per request. On an Envoy-based gateway, add %DOWNSTREAM_TLS_VERSION% and %DOWNSTREAM_TLS_CIPHER% to your access-log format, then aggregate for a week:

bash
# Which protocol versions are your clients actually negotiating?
kubectl logs -n gateway-system deploy/gateway \
  | grep -o 'TLSv1\.[0-9]' | sort | uniq -c | sort -rn

Anything below TLS 1.2 in that histogram is a client Heroku would now refuse. Contact those owners before you tighten the suite list, and you turn a breaking change into a notification.


Audit your own domains tonight

Run this sequence against every domain you terminate — platform domains and customer custom domains alike. It takes about ten minutes per host and produces evidence, not vibes.

Step 1 — probe the floor with openssl. The -brief flag prints exactly two lines that matter per probe: Protocol version and Ciphersuite on success, an error: line on refusal. Read the output, don't just check the exit code:

bash
DOMAIN=app.example.com
 
# Legacy surface: a 'CONNECTION ESTABLISHED' line here is the finding.
# (I ran the AES128-SHA probe against example.com while writing this:
#  it negotiated TLS 1.2 with AES128-SHA — a pre-AEAD CBC suite, live in 2026.)
openssl s_client -brief -connect $DOMAIN:443 -tls1_2 -cipher 'AES128-SHA' </dev/null 2>&1 | grep -E 'CONNECTION|Protocol|Ciphersuite|error'
openssl s_client -brief -connect $DOMAIN:443 -tls1_2 -cipher 'DES-CBC3-SHA' </dev/null 2>&1 | grep -E 'CONNECTION|Protocol|Ciphersuite|error'
 
# Modern baseline: both of these MUST establish a connection.
openssl s_client -brief -connect $DOMAIN:443 -tls1_2 </dev/null 2>&1 | grep -E 'CONNECTION|Protocol|Ciphersuite|error'
openssl s_client -brief -connect $DOMAIN:443 -tls1_3 </dev/null 2>&1 | grep -E 'CONNECTION|Protocol|Ciphersuite|error'

One honest caveat: a modern OpenSSL 3.x client may refuse to offer TLS 1.0/1.1 at all (no protocols available is your client talking, not the server), which makes -tls1 probes inconclusive rather than passing. That is exactly what Steps 2 and 3 are for — sslyze and testssl.sh craft their own handshakes instead of relying on your system OpenSSL.

Step 2 — scan broadly with sslyze. This enumerates every accepted suite per protocol version and flags known vulnerabilities in one JSON artifact you can diff over time:

bash
pip install sslyze
sslyze --regular "$DOMAIN" --json_out "/tmp/sslyze-$DOMAIN.json"
# Every suite the server actually accepted, deduplicated:
python3 -m json.tool "/tmp/sslyze-$DOMAIN.json" | grep -i 'openssl_name' | sort -u

Step 3 — go deep with testssl.sh. This is the most comprehensive free scanner and the one whose output your customers' auditors already trust:

bash
git clone --depth 1 https://github.com/testssl/testssl.sh.git /tmp/testssl
/tmp/testssl/testssl.sh --severity HIGH --quiet "$DOMAIN"

One corroboration rule, borrowed from penetration-testing practice: never act on a lone scanner line. Confirm every testssl.sh or sslyze hit with a real openssl s_client handshake using that exact cipher — a completed handshake on a weak suite is the proof. Then fix the server, re-run all three steps, and keep the JSON output: next quarter's audit is a diff, not a project.


The TLS baseline for your own platform

Here is the part Heroku tenants can never do for themselves: set the policy explicitly. This baseline follows Mozilla's Intermediate profile — TLS 1.2 minimum, AEAD suites with forward secrecy, TLS 1.3 preferred — expressed as cert-manager plus Gateway API resources. TLS 1.3 suites need no configuration (the spec only permits the five secure ones), so every cipher string below governs TLS 1.2 only.

Certificates with cert-manager (automated issuance and renewal — mandatory now that public certificates are shrinking toward 47 days):

First, the issuer (DNS-01 via your DNS provider — shown here with Cloudflare):

yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    privateKeySecretRef:
      name: letsencrypt-prod-key
    solvers:
      - dns01:
          cloudflare:
            email: ops@example.com
            apiTokenSecretRef:
              name: cloudflare-api-token
              key: api-token

Then the wildcard certificate for the platform domain:

yaml
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: platform-wildcard
  namespace: gateway-system
spec:
  secretName: platform-wildcard-tls
  issuerRef:
    name: letsencrypt-prod
    kind: ClusterIssuer
  dnsNames:
    - "*.onbex.co"
    - "onbex.co"

Use DNS-01 rather than HTTP-01 for wildcard platform domains and customer custom domains: it works behind the gateway before any routing exists, which is exactly when a new tenant's first certificate must be issued.

Minimum version and suites on the gateway via Envoy Gateway's ClientTrafficPolicy (see the secure-gateways task):

yaml
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: platform-gateway
  namespace: gateway-system
spec:
  gatewayClassName: envoy
  listeners:
    - name: https
      port: 443
      protocol: HTTPS
      hostname: "*.onbex.co"
      tls:
        mode: Terminate
        certificateRefs:
          - name: platform-wildcard-tls
      allowedRoutes:
        namespaces:
          from: All

And the policy that pins the TLS floor to that gateway:

yaml
apiVersion: gateway.envoyproxy.io/v1alpha1
kind: ClientTrafficPolicy
metadata:
  name: tls-baseline
  namespace: gateway-system
spec:
  targetRefs:
    - group: gateway.networking.k8s.io
      kind: Gateway
      name: platform-gateway
  tls:
    minVersion: "1.2"
    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
      - DHE-RSA-AES128-GCM-SHA256
      - DHE-RSA-AES256-GCM-SHA384

Two notes that save debugging sessions. First, HTTP/2 to browsers only happens over TLS with ALPN negotiating h2 — terminating TLS at the gateway and advertising h2 is what makes Heroku-style protocol=http2.0 appear in your logs; a plaintext backend behind a TLS gateway is fine. Second, apply the same ClientTrafficPolicy to the listener that serves customer custom domains. Heroku's alignment of default and custom-domain suites is the real lesson: two different TLS postures means two audit surfaces, and attackers (and auditors) always pick the weaker one.


The Heroku lesson for self-hosters

Strip away the specifics and Heroku's announcement is a governance story, not a cryptography story. A managed platform decided, on a date certain, that compatibility with 2011-era clients was no longer worth the risk — and because Common Runtime tenants cannot customize ciphers, the decision applied to everyone at once, with no per-app migration path and no way to opt out. Tenants got security by fiat. They also got breakage by fiat, on someone else's schedule.

Self-hosting inverts both halves. Nobody can break your clients on a Monday morning without your approval — but nobody hardens your gateway without your action either. The checklist that falls out of Heroku's change:

  • Minimum TLS 1.2 everywhere, TLS 1.3 preferred; refuse 1.0/1.1 unconditionally.
  • AEAD suites with forward secrecy only; nothing from the four dead families.
  • One TLS posture for platform domains and customer domains — no weaker second surface.
  • Per-connection tls_version + cipher logging, reviewed before every tightening.
  • Automated issuance and renewal (cert-manager + DNS-01), because short-lived certificates are already here.
  • Re-run the three-step audit quarterly; diff the sslyze JSON, don't re-argue the policy.

Heroku's twelve ciphers had a good run — some of them are older than the developers now being paged about them. Give yours the same retirement party, on your schedule instead of someone else's.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex