Vulnerability Research
Erlang CVE-2026-89422: TLS without server authentication
Erlang/OTP's TLS 1.3 client could treat an unsolicited PSK selection as permission to resume a session and skip server certificate checks despite verify_peer; update the runtime actually used by the application, restrict affected clients to TLS 1.2 while waiting, and verify that authentication works again.

In this article
A TLS client configured with verify_peer and trusted CAs is supposed to establish the server's identity before returning a successful connection. CVE-2026-89422, disclosed by Erlang/OTP on September 22, lets an affected client accept a peer that never sends a certificate. The application still receives {ok, Socket}, and its subsequent traffic is encrypted. It has simply handed that traffic to whoever could answer the connection, without establishing who that peer is.
The implementation combined two different TLS 1.3 handshake paths. A server-supplied extension put the client into session-resumption mode, which omits certificate checks. When the client then tried to obtain the pre-shared key, the absence of a local key sent it back to the default input for an ordinary full handshake. The peer could complete key agreement without supplying the identity evidence that the full handshake required.
Our recommendation is to identify running nodes that initiate TLS 1.3 connections through OTP ssl and update their actual runtime packages. Until that is possible, restrict those clients to TLS 1.2. Checking only inbound HTTPS servers, reinstalling trusted CAs or disabling session tickets misses this failure. To understand why, start with the legitimate reason TLS can omit certificates, then follow the state that lost that condition.
1 When TLS can omit a certificate
A full TLS 1.3 handshake ordinarily establishes both connection keys and the server's identity. Ephemeral key exchange produces a shared secret. The certificate and CertificateVerify signature bind the handshake to a trusted identity. The later Finished message checks the exchanged handshake using a derived key. A peer that knows the handshake secret can calculate that check; trust in its identity still depends on the preceding authentication steps.
On a later connection, the client can use a session ticket and its associated pre-shared key, or PSK. That handshake can omit a fresh certificate exchange because authentication rests on a secret the peers already hold. The client offers PSK identities, and the server uses selected_identity to choose one. This is an index into the client's list, not a new key that the server is free to invent.
RFC 8446 section 4.2.11 consequently requires the client to check that the selected index is within the range it offered. If the client offered nothing, there is no valid selection. An inconsistent selection must terminate the handshake with illegal_parameter. This check connects the server's request for resumption to authentication material the client actually has.
The omitted certificate steps therefore have a prerequisite: a usable PSK has been negotiated. Vulnerable OTP treated the presence of the extension as sufficient, then allowed key lookup to fail without stopping the handshake. The resumption flag and the key actually used no longer agreed. The connection proceeded with neither certificate authentication nor genuine PSK authentication.
2 The state says resume; the key lookup says default
Our fixed pre-patch observation point is commit 751f87b703fe5948607d08e82599ce644b772e76. The extension decoder in ssl_handshake.erl turns the ServerHello selection into #pre_shared_key_server_hello{selected_identity = Identity}. It checks the data's shape and duplicate extensions. Whether the client offered the identity still requires the client's saved state.
In handle_server_hello/2, lines 794–805 retrieve that extension and, after version and handshake-retry checks, pass it to handle_resumption/2. The latter has two clauses: an absent extension leaves the state alone; a present extension sets handshake_env.resumption to true. These five lines never inspect the client's offered identities.
%% Before the fix: a present extension sets resumption mode
handle_resumption(State, undefined) ->
State;
handle_resumption(#state{handshake_env = HSEnv0} = State, _) ->
HSEnv = HSEnv0#handshake_env{resumption = true},
State#state{handshake_env = HSEnv}.
Cipher-suite and key-share checks follow. Only at line 831 does handle_server_hello/2 call get_pre_shared_key/4. That function returns the same zero value for two different situations: the server selected no PSK, or it selected a PSK while the client had no usable ticket. Lines 1147–1153 show the shared fallback. The no-ticket cases in manual and auto mode also fall back this way.
%% Two clauses excerpted from the vulnerable implementation
%% Fourth argument undefined: the server selected no PSK
get_pre_shared_key(_, _, HKDFAlgo, undefined) ->
{ok, binary:copy(<<0>>, ssl_cipher:hash_size(HKDFAlgo))};
%% Second argument undefined: the client has no usable ticket
get_pre_shared_key(_, undefined, HKDFAlgo, _) ->
{ok, binary:copy(<<0>>, ssl_cipher:hash_size(HKDFAlgo))};
The zero value is the hash-length input used when there is no PSK, a normal part of full-handshake key derivation. The final traffic key also depends on other material: OTP's calculate_handshake_secrets/5 and its callees incorporate the fresh key-exchange result. A peer participating in that exchange can derive the same handshake secret and produce a valid Finished. Cryptographic calculations continue normally while server authentication has been omitted.
Meanwhile, resumption = true remains in the state. After EncryptedExtensions arrives, handle_encrypted_extensions/2 calls maybe_resumption/1. With that flag set, the next state is immediately wait_finished. The ordinary certificate-processing path through wait_cert_cr, wait_cert and wait_cv is skipped.
The return value here is {error, {State, wait_finished}}, which can misleadingly look like an aborted handshake. It is internal control flow: do_maybe/0 throws it, and the surrounding catch extracts the next state and continues. This error tuple is not a TLS failure returned to the application. Certificate-chain and signature checks live in the skipped process_certificate/2 and verify_certificate_verify/2. The configured verify_peer never gets to do its work there.

Scroll sideways to read the diagram.
Finally, wait_finished/3 checks Finished, derives application traffic keys and enters the connection state. By the time the application sees success and sends a token or request body, it has passed the point where the peer should have been rejected. Encrypted packets and an error-free handshake are insufficient acceptance criteria for this update.
3 Reject the selection before changing authentication mode
Fix commit afec515, committed on September 18, 2026, subsequently shipped in OTP 29.1.1. It preserves the normal zero-value clause when the server selects no PSK. Cases where the server does select one but the client lacks a candidate now raise fatal illegal_parameter. The complete repaired selection logic covers unconfigured tickets, missing tickets and the no-match paths in manual and automatic modes. Automatic mode still releases ticket locks before exiting.
%% After the fix: a PSK selection arrives without a usable client ticket
get_pre_shared_key(_, undefined, _, ServerPSK) ->
{error, ?ALERT_REC(?FATAL, ?ILLEGAL_PARAMETER,
{unsolicited_pre_shared_key, ServerPSK})};
That rejection closes the faulty path. The patch also moves handle_resumption/2 after successful get_pre_shared_key/4, at lines 827–846. The implementation now accepts the peer's selection before allowing it to change the authentication path. An unsolicited PSK terminates processing in wait_sh, before handshake-secret derivation or certificate-state selection.
The original behavior has a long history. Commit 21b8a1b added client PSK resumption in 2019, including the transition from wait_ee directly to wait_finished. The official affected range begins with OTP 22.2. Code has since been split and refactored: the old commit establishes the feature's origin, while this article's line references identify the particular pre-fix and fixed snapshots, not every historical release.
Upstream added tls13_reject_unsolicited_psk for default ticket-disabled mode and automatic mode with an empty store. Its assertion requires illegal_parameter; a successful connection, timeout or unrelated error does not pass. The commit records the maintainer's before-and-after result: acceptance without the fix and rejection with it. We inspected those assertions and the relevant implementation. We did not run a complete Erlang TLS reproduction locally, and the maintainer's experiment is not a SOSEC measurement.
4 Find outbound clients and update their running OTP
The official advisory identifies consumers of ssl:connect that negotiate TLS 1.3, including HTTPS through httpc, database and messaging clients, and the initiating side of TLS distribution connections. Inventory the process, its actual OTP runtime and its TLS destinations. Elixir applications also need this check: the relevant question is whether the particular library uses OTP ssl for that connection.
Ticket caching need not be enabled. Default session_tickets = disabled is affected, as are manual and auto when the client has no ticket to offer, such as a first connection. The attacker must be able to answer the connection, either as the malicious host being contacted or as an on-path actor able to intercept and respond. A role that only accepts inbound TLS and never initiates an affected connection does not encounter this client flaw merely by using OTP. Check separately for outbound APIs, directories, messaging and other connections made by that same service.
As of September 23, 2026, the current official fixed releases are below. For this disclosure, the first-fixed versions also happen to be the current targets in their respective branches. The matching ssl application versions help verify what a package actually contains.
| Existing OTP branch | Current fixed target | Included ssl and patch |
|---|---|---|
| 29 | 29.1.1 | 11.7.7; afec515 |
| 28 | 28.5.0.7 | 11.6.0.6; 98c66c8 |
| 27 | 27.3.4.18 | 11.2.12.13; fd1d9d0 |
The affected range begins with OTP 22.2 and ssl 9.5. Older deployments on 22–26 are not excluded because the table lists only 27–29. Move them to an appropriate fixed branch or obtain a distributor package that explicitly backports this CVE's fix. Compare versions within their branches: OTP's version scheme forms a tree, so the fact that 28 is numerically larger than 27.3.4.18 cannot make OTP 28.0 fixed. Operating-system packages may also retain an older upstream version while backporting the change; consult their security records.
Prefer a complete, compatible runtime package or vendor image over copying a single ssl directory. The 29.1.1 release notes specify a public_key dependency for applying the SSL update separately. The 28 branch also has crypto and public_key requirements, and 27 has its own floor. This release batch additionally changes SSH's default connection and channel limits. Deployments using OTP SSH should test that compatibility change and check whether their connection volumes require configuration changes.
These local diagnostic calls can be used in the application's actual running node console. They send no TLS requests. Running them in a separate temporary erl installation says nothing about the production node.
application:get_key(ssl, vsn).
ssl:versions().
code:which(tls_handshake_1_3).
code:module_status(tls_handshake_1_3).
code:module_status(tls_client_connection_1_3).
get_key/2 reads application metadata. An undefined result requires checking whether the application is loaded; it is not a clean bill of health. ssl:versions/0 reports ssl_app and environment-supported protocols, which connection-specific options can override. module_status/1 can expose a modified mismatch between loaded code and the file on disk. Even loaded means only that they match; establish that the matching package contains the fix. Containers, embedded releases and hot-updated nodes particularly need package identity, load paths and rollout results checked together.
When an immediate upgrade is impossible, the official temporary measure is to restrict affected clients to TLS 1.2. This is the relevant client TLS option, not a listener setting or a global instruction that automatically changes every library:
{versions, ['tlsv1.2']}
Preserve verify_peer, trusted CAs, server-name settings and application authentication, then make connection pools establish fresh connections. The cost is temporarily losing TLS 1.3. Peers that accept only TLS 1.3 will become unreachable and need an alternative or a planned pause. The vendor offers no configuration that retains TLS 1.3 while mitigating the flaw. Disabling tickets, replacing CAs or switching failures to verify_none does not substitute for the update.
Restart or roll nodes using the application's release procedure, confirm old instances and connections requiring renewal have exited, and then restore the original TLS 1.3 policy. If the update fails, retain the TLS 1.2 restriction already verified against the application. A rollback to an older runtime must retain that restriction. Restoring the original TLS 1.3 policy requires at least the branch-specific fixed version in the table, or a package with explicit backport evidence. Other security updates and product compatibility requirements can further constrain rollback choices.
5 Verify that the wrong connections fail
The testing trap is that a vulnerable client can connect perfectly well to an ordinary server. One successful request never reaches the branch where a peer selects an identity the client does not have. Keep that success case as a control, then check invalid identities and unsupported resumption conditions. Teams maintaining their own builds or backports can use the upstream regression in an isolated environment they control. Production services do not need to receive constructed abnormal handshakes.
| Condition | Expected result | What it checks |
|---|---|---|
| Normal server, trusted certificate, correct name and a fresh TLS 1.3 connection | Connection and application requests succeed | The full handshake and application still work |
| An untrusted certificate or incorrect server name in a test environment | Rejection under the existing authentication policy | The update did not silently disable identity checks |
| Upstream unsolicited-PSK regression, with tickets disabled and automatic mode's empty store | illegal_parameter; no application connection state | The selection is rejected during ServerHello; a timeout does not pass |
| A valid ticket from an authenticated connection, followed by legitimate resumption | Resumption allowed by application policy succeeds | Certificate omission was not indiscriminately banned |
| Temporary TLS 1.2 restriction, then TLS 1.3 restoration after updating | Fresh connections negotiate the intended protocol while certificate and application authentication remain active | The restriction and its removal reach the actual clients, connection pools and every node |
This is a proposed acceptance matrix, not a record of tests performed for this article. The new upstream case focuses on default mode and an empty automatic ticket store, with a fixed cipher suite and key-exchange group. It does not replace each application's proxy, hostname, pooling and legitimate-resumption tests. For users of packaged software, distributor confirmation, actual runtime identity and positive and negative authentication controls usually fit release acceptance better than adapting a handshake test themselves.
For historical investigation, the advisory identifies two observations worth retaining: ssl:peercert/1 returns {error, no_peercert} despite required peer verification, and connection information reports {session_resumption, true} when the client never held a ticket. Evaluate them with the contemporaneous client options, ticket state, destination and application logs. Resumption alone is normal, and a legitimate resumed session can omit a new certificate exchange. Missing historical observations cannot rule out a previous occurrence.
If evidence shows sensitive requests went to an unexpected peer, examine the tokens, credentials and business operations sent on those connections, revoke affected credentials and investigate the consequences of accepted responses. Updating prevents new authentication failures; it cannot retrieve secrets already sent. Scope follow-up to the actual connections and data, without assuming that every server certificate needs replacement.
Research status is bounded at September 23, 2026, 02:00 UTC. EEF assigns CVSS 4.0 severity 9.3. The current NVD record is Deferred and displays EEF's score and CWE-322, with no separate NIST score. The retrieved 2026.09.22 CISA KEV catalog does not contain this CVE; CISA's September 22 SSVC entry in the CNA record says Exploitation: none. These are dated observations of named sources, not a claim that exploitation is impossible or a conversion of severity into victim counts.
The useful engineering check is to trace any state that permits authentication to be omitted back to its evidence. A peer field can request resumption; the client must first confirm that it holds the corresponding PSK before omitting certificate validation. Upgrade acceptance should reach the same level: fixed code is running in the actual nodes, invalid identities are rejected, and normal connections and legitimate resumption still work. Together these results restore the authentication guarantee the application depends on.
Research basis
Research basisStatic tracing of TLS 1.3 client extension parsing, PSK selection, resumption and certificate states at pre-fix commit 751f87b; comparison of fixes afec515, 98c66c8 and fd1d9d0 and upstream regression assertions; review of current OTP 27, 28 and 29 releases. No Erlang exploit reproduction or external service probing was performed. Official status checked through 2026-09-23 02:00 UTC.
SourceErlang/OTP advisories, pinned source and fixes for three branches; RFC 8446; EEF CNA, NVD and CISA KEV
Evidence confidence High
6Evidence and sources
6.1Timeline
- Client PSK resumption enters the source
21b8a1b adds the state transition; the official affected range starts at OTP 22.2.
- Fixes committed for three branches
afec515, 98c66c8 and fd1d9d0 reject unsolicited PSK selections and move resumption-state assignment.
- Advisory and fixed releases published
OTP 29.1.1, 28.5.0.7 and 27.3.4.18 ship; EEF publishes CVE-2026-89422.
6.2Sources and material
- Erlang/OTP advisory GHSA-rgxr-4g4w-j875https://github.com/erlang/otp/security/advisories/GHSA-rgxr-4g4w-j875
- EEF CNA record and CISA enrichmenthttps://cveawg.mitre.org/api/cve/CVE-2026-89422
- OTP 29 fix and regression testhttps://github.com/erlang/otp/commit/afec5156361bb50d3607c7c1a453c19b9149b324
- OTP 28 fixhttps://github.com/erlang/otp/commit/98c66c858113949c4262d26cd7d426c4b09d2b35
- OTP 27 fixhttps://github.com/erlang/otp/commit/fd1d9d07fc92ec0d59f96dfb66182195882bb7dd
- RFC 8446: PSK selectionhttps://www.rfc-editor.org/rfc/rfc8446.html#section-4.2.11
- RFC 8446: key schedulehttps://www.rfc-editor.org/rfc/rfc8446.html#section-7.1
- OTP version orderinghttps://www.erlang.org/doc/system/versions.html#order-of-versions
- OTP ssl APIs and client optionshttps://www.erlang.org/doc/apps/ssl/ssl.html
- NVD record and change-history accesshttps://nvd.nist.gov/vuln/detail/CVE-2026-89422