What happens to Go's post-quantum TLS default when a service sets tls.Config.CurvePreferences explicitly?
answer
- the field replaces, it does not extend
- an unset field is not an empty set
- an old hardening list is now a downgrade
- order in the slice is preference order
- there is a GODEBUG escape hatch too
basics
~20 sCurvePreferences is an allow-list, not an addition. A non-nil list negotiates only the groups it names, in that order, so any list written before Go 1.24 silently removes X25519MLKEM768. Include tls.X25519MLKEM768 explicitly, or leave the field nil.
solid answer
~40 s`tls.Config.CurvePreferences` replaces the standard library's list rather than extending it. `nil` means "use Go's defaults", which since Go 1.24 put `X25519MLKEM768` first; any non-nil slice restricts the handshake to exactly the groups it names, in the order it names them. So a hardening config written years ago as `[]tls.CurveID{tls.X25519, tls.CurveP256}` still compiles, still looks strict, and quietly opts the service out of post-quantum key exchange. The fixes are to delete the field or to put `tls.X25519MLKEM768` at the front of the list. The same lever works deliberately in both directions: listing only `tls.X25519MLKEM768` forces the hybrid group and makes peers that lack it fail the handshake, while `GODEBUG=tlsmlkem=0` turns it off at run time without touching code. Auditing existing `tls.Config` literals for a stale `CurvePreferences` is the practical takeaway.
code
go · 15 lines// Copied from a pre-1.24 hardening guide. Still compiles, and silently
// drops X25519MLKEM768 because the field is an allow-list.
stale := &tls.Config{
CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256},
}
// Keeps the hybrid group while still pinning the rest, in preference order.
fixed := &tls.Config{
CurvePreferences: []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256},
}
// Hard requirement: peers without the hybrid group now fail the handshake.
forced := &tls.Config{
CurvePreferences: []tls.CurveID{tls.X25519MLKEM768},
}go deeper
Remember that leaving tls.Config.CurvePreferences unset is what gets you Go's current defaults, and that writing your own list means you now own keeping it up to date.
Explain the allow-list semantics precisely: non-nil restricts the handshake to exactly those groups in that order, so a pre-1.24 list silently removes X25519MLKEM768 while still compiling.
Show the audit instinct. Reviewing a diff, ask what a CurvePreferences entry removes; in production, verify by counting negotiated groups rather than trusting that a toolchain upgrade reached the handshake.
Decide the standard: whether services pin curve lists at all, or track toolchain defaults so protocol changes arrive with an upgrade instead of a hundred repository edits.
## The field ```go type Config struct { // CurvePreferences contains the elliptic curves and key exchange // mechanisms that will be used in an ECDHE handshake, in preference order. CurvePreferences []CurveID // ... } ``` Two properties of this field decide the whole answer, and both surprise people. **It is an allow-list, not an addition.** Whatever you put in the slice is the complete set of groups the handshake may use. Nothing outside the slice is negotiable. There is no "defaults plus mine" mode. **`nil` is not "none", it is "Go decides".** An unset `CurvePreferences` hands the choice to the standard library's own preference list, and that list is what Go 1.24 changed by putting `X25519MLKEM768` at the front. This is the ordinary Go convention of a zero value meaning a sensible default, and here it means the security-relevant default tracks your toolchain automatically. ## The trap Put those two together and you get the failure this leaf exists for: ```go cfg := &tls.Config{ CurvePreferences: []tls.CurveID{tls.X25519, tls.CurveP256}, } ``` That line was good advice for years — pin the curves, drop the weak ones, be explicit. On Go 1.24 and later it is a downgrade. It compiles, it passes review, it looks *more* hardened than the config next to it, and it removes post-quantum key exchange from every connection the service makes. Nothing warns you. The handshake succeeds; it just negotiates a classical curve. This is the reason a Go upgrade alone does not tell you your fleet got post-quantum protection. The only way to know is to look: audit every `tls.Config` literal for a non-nil `CurvePreferences`, and count real handshakes by negotiated group (recent Go exposes it as `ConnectionState.CurveID`) rather than trusting the release notes. ## The three postures Once you understand the field as an ordered allow-list, all three deliberate configurations fall out of it. **Track the defaults — leave it nil.** The best choice for almost every service. You inherit whatever the toolchain considers current, which means the next protocol default arrives with your next Go upgrade instead of requiring a code change in every repository. **Keep an explicit list but lead with the hybrid group.** ```go cfg := &tls.Config{ CurvePreferences: []tls.CurveID{tls.X25519MLKEM768, tls.X25519, tls.CurveP256}, } ``` Use this when a policy genuinely requires a written-down list. Understand that you have now taken ownership of keeping it current: the next hybrid group Go adds will not appear in your handshakes until someone edits this slice. **Force the hybrid group — list only it.** ```go cfg := &tls.Config{ CurvePreferences: []tls.CurveID{tls.X25519MLKEM768}, } ``` This converts a silent downgrade into a loud failure: a peer that does not support the group cannot complete the handshake. That is exactly what you want between two endpoints you control, and exactly what you do not want facing partners whose stacks you cannot upgrade. ## Ordering matters, not just membership The slice is a *preference order*. In TLS 1.3 the client sends key shares for its top preferences and the server picks; if the server prefers a group for which the client sent no share, the protocol recovers with a HelloRetryRequest — correct, but it costs an extra round trip. Putting a group in the list that the other side is unlikely to want first is therefore a latency decision as well as a security one. ## The run-time lever `GODEBUG=tlsmlkem=0` disables the hybrid group without any code change, in a single process or across a fleet through the environment. It exists precisely so an operator can back out of a new protocol default at 3am without a rebuild. Treat it as an incident tool with an expiry date, not as configuration: it is fleet-wide and blunt, whereas a `CurvePreferences` list on one client config is surgical. If exactly one partner endpoint is broken, the code-level fix scoped to that destination is the better answer, because it leaves every other connection protected. ## The review habit When you see a `CurvePreferences` in a diff, ask one question: what does this *remove*? The field never adds anything. Every entry not in the slice is off, including entries that did not exist when the slice was written.
- How do you deliberately force the hybrid group and what breaks?Set `CurvePreferences` to `[]tls.CurveID{tls.X25519MLKEM768}` alone. Any peer that does not offer that group now fails the handshake instead of quietly falling back. That is the right trade only when you control both ends, or when a hard failure is genuinely preferable to a silent classical downgrade.
- What is the difference between GODEBUG=tlsmlkem=0 and editing CurvePreferences?`GODEBUG=tlsmlkem=0` is an environment-level, whole-process switch that needs no rebuild and hits every connection the binary makes. Editing `CurvePreferences` is a code change scoped to one `tls.Config`, so it can disable the group for one destination and leave everything else protected. Prefer the scoped fix; keep the GODEBUG for incidents.
- Does listing groups here affect a TLS 1.2 handshake?It constrains the curves available to TLS 1.2's ECDHE, but `X25519MLKEM768` itself is a TLS 1.3 group and has no effect on a 1.2 connection. So a service that ends up negotiating TLS 1.2 gets no post-quantum protection regardless of what the preference list says.
- How would you audit a large codebase for this?Grep for `CurvePreferences` across the repositories and treat every non-nil literal as suspect, then verify empirically: record the negotiated group per handshake and compare the share using the hybrid group before and after the audit. Code inspection finds the stale lists; the counter proves the fix reached production.
It behaves like a guest list, not a plus-one. Anyone you forget to write down is turned away, including guests who only moved into the neighbourhood after you wrote the list.
saying these in an interview costs you the question
- Thinks CurvePreferences adds groups on top of Go's defaults
- Reads a nil CurvePreferences as disabling all groups
- Says an explicit curve list is always more secure than none
- Forgets that the slice is an ordering, not just a set
- Reaches for a fleet-wide GODEBUG when one peer is broken