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, 2026 | After | |
|---|---|---|
| Minimum protocol | TLS 1.2 (1.0/1.1 refused for years) | TLS 1.2 |
| TLS 1.3 | Partial availability | Enabled across Common Runtime endpoints |
| HTTP/2 | Opt-in, limited to custom domains | Available broadly (check protocol=http2.0 in router logs) |
| Cipher suites | Broad compatibility set, including pre-AEAD legacy suites | Curated AEAD-only set with forward secrecy |
| Default vs custom domains | Different suite lists | Aligned 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:
| Family | Example suites | What killed it | Why it cannot be saved |
|---|---|---|---|
| RC4 stream cipher | RC4-SHA, ECDHE-RSA-RC4-SHA | Keystream 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-SHA | SWEET32 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 exchange | AES128-SHA, AES256-SHA, DES-CBC3-SHA | BEAST (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 / NULL | EXP-*-*, ADH-*, NULL-* | Deliberately breakable by design (1990s export law) or unauthenticated | No 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:
| Client | Why it fails after the cull | What to do |
|---|---|---|
| Java 6, or Java 7 with defaults | No 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.7 | Defaults to TLS 1.0/1.1 unless ServicePointManager.SecurityProtocol sets TLS 1.2 | Set TLS 1.2 in code or registry; upgrade to 4.7+ where 1.2 is the default |
| Android 4.3 and older | TLS 1.2 missing (4.1–4.3) or disabled by default (4.4); only legacy suites offered | Drop 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.0 | No TLS 1.2 support at all | Upgrade interpreter and OpenSSL; this stack is end-of-life twice over |
| curl / wget / scripts on OpenSSL before 1.0.1 | TLS 1.0-only handshakes | Rebuild against a maintained OpenSSL |
| Embedded devices and IoT firmware with hardcoded suite lists | Handshake failure with no useful error — the device just goes dark | Firmware 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:
# Which protocol versions are your clients actually negotiating?
kubectl logs -n gateway-system deploy/gateway \
| grep -o 'TLSv1\.[0-9]' | sort | uniq -c | sort -rnAnything 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:
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:
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 -uStep 3 — go deep with testssl.sh. This is the most comprehensive free scanner and the one whose output your customers' auditors already trust:
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):
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-tokenThen the wildcard certificate for the platform domain:
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):
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: AllAnd the policy that pins the TLS floor to that gateway:
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-SHA384Two 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
sslyzeJSON, 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.



