skip to content

Why do requests with no client certificate succeed on a Go TLS server that checks r.TLS.PeerCertificates?

level: seniorimportance: should knowfreq 31%

answer

  1. the check is conditional on the caller
  2. an empty slice takes no branch
  3. one ClientAuthType value is the zero value
  4. strict for credential holders, absent otherwise
  5. the happy-path test cannot see it

basics

~20 s

Almost always because ClientAuth is not RequireAndVerifyClientCert. Under NoClientCert, RequestClientCert or VerifyClientCertIfGiven the handshake completes with no certificate, the peer chain is empty, and a guard written as if there is a certificate then check it never runs.

solid answer

~50 s

The handler's check is conditional on evidence the caller controls. If `ClientAuth` is anything short of `tls.RequireAndVerifyClientCert` — and `tls.NoClientCert` is the zero value, so an unset field qualifies — the handshake succeeds without a certificate, `r.TLS.PeerCertificates` comes back empty, and a guard shaped `if len(...) > 0 { authorise }` skips the authorisation entirely. The check passes vacuously: it is strictest against the callers that present credentials and absent for the ones that do not. The other common causes are a `tls.Config` that is not the one actually serving, and a TLS-terminating proxy in front, which leaves `r.TLS` nil so the same guard skips again. Fix it in two places: set `RequireAndVerifyClientCert` so unauthenticated connections die at the handshake, and rewrite the handler to derive an identity and reject when there is not one.

code

go · 14 lines
go
// fail-open: no certificate means the authorisation branch never runs
if len(r.TLS.PeerCertificates) > 0 {
	if !allowed[r.TLS.PeerCertificates[0].DNSNames[0]] {
		http.Error(w, "forbidden", http.StatusForbidden)
		return
	}
}

// fail-closed: an unidentified caller is rejected like a rejected one
id, ok := caller(r)
if !ok || !allowed[id] {
	http.Error(w, "forbidden", http.StatusForbidden)
	return
}

go deeper

for a junior

Recall that a certificate is only present if the server asked for and required one, and that an empty peer chain makes any 'if a certificate exists' branch simply not run.

for a middle

Explain which ClientAuthType values let a certificate-free handshake complete, that NoClientCert is the zero value, and how an empty slice turns a real check into a skipped one.

for a senior

Diagnose it end to end: find the serving config, prove the gap with a request that sends no certificate, fix the transport and invert the handler guard, and add the negative test that keeps it fixed.

for a principal

Own the rule that authentication fails closed everywhere, and the migration policy that stops a temporary permissive mode from becoming the permanent one nobody notices.

## The shape of the bug The code looks defensive. Somebody wrote: ```go if len(r.TLS.PeerCertificates) > 0 { if !allowed[r.TLS.PeerCertificates[0].DNSNames[0]] { http.Error(w, "forbidden", http.StatusForbidden) return } } ``` Every caller that presents a certificate is checked against the allow-list. Every caller that presents nothing walks straight past. The guard is not wrong about the callers it examines; it is that the decision to examine is taken from the caller. This is the vacuous pass, and it is the characteristic failure of client-certificate authentication written as an optional enrichment of the request rather than as a gate. ## Why the certificate is missing **The ClientAuth mode.** `tls.NoClientCert` is the zero value of `ClientAuthType`, so a config that sets `ClientCAs` and forgets `ClientAuth` never requests a certificate at all — and the handler's guard then skips for everyone, forever, silently. `tls.RequestClientCert` asks but accepts nothing in reply and verifies nothing it does get. `tls.VerifyClientCertIfGiven` verifies what arrives but still lets an empty-handed client complete the handshake. All three produce exactly the symptom described. Only `tls.RequireAndVerifyClientCert` makes an absent certificate a handshake failure. There is also `tls.RequireAnyClientCert`, which fails a different way: it demands a certificate but does not verify it, so `PeerCertificates` is non-empty with something the caller minted, `VerifiedChains` is empty, and a guard reading `PeerCertificates` authorises on an attacker-chosen name. **The config is not the serving one.** The `tls.Config` gets built, and then the process starts the listener some other way — a second `http.Server` in a test helper, a listener wrapped before the config was attached, a config value copied after `ListenAndServeTLS` already began. The struct in the code review is not the struct in the handshake. **TLS terminated upstream.** If a proxy or load balancer terminates TLS and forwards plaintext, `r.TLS` is nil in the handler. The same conditional guard skips, and whatever identity the proxy extracted is now a header — trustworthy only if nothing but that proxy can reach the listener. ## Diagnosing it The honest test is an integration test that makes the *same request twice*: once with a client certificate and once without, asserting that the second attempt fails. A test that only exercises the happy path passes identically whether the server requires certificates or ignores them, which is why the bug survives review — every existing test still goes green. Running it against a real `httptest` server with the production `tls.Config` also catches the wrong-config variant, because the assertion is about behaviour, not about a field's value. By hand the equivalent is dialling with `tls.Dial` or an `http.Client` carrying no `Certificates` and confirming the connection is refused. When it is refused, the server side records a `http: TLS handshake error from ...` line on `http.Server.ErrorLog`, and that absence-of-log is itself diagnostic: if unauthenticated attempts produce no handshake errors at all, nothing is being rejected. ## Fixing it in both layers 1. **Transport.** `ClientAuth: tls.RequireAndVerifyClientCert` with a `ClientCAs` pool containing only your internal issuer. Unauthenticated connections now die before a request exists. 2. **Handler.** Invert the guard so absence of an identity is a rejection. Extract the caller — nil `r.TLS`, empty `VerifiedChains` and a leaf with no usable name all return 'not identified' — and reject when extraction fails. The transport should already have made that branch unreachable, and the point is that it stays a rejection if someone later relaxes the mode or puts a proxy in front. That second layer is not redundancy theatre. Transport configuration is the thing most likely to be changed by a different team for an unrelated reason: a health check that could not present a certificate, a migration window that softened the mode 'temporarily'. A handler that fails closed turns that change into a visible outage instead of an invisible opening. ## The migration trap Teams moving an internal service off shared secrets usually run both credentials for a while, and the natural interim setting is `VerifyClientCertIfGiven` so old callers keep working. That is a defensible waypoint, but only if the handler treats a missing certificate as 'fall back to the shared secret and record it', never as 'skip the check'. The dangerous version of this migration is the one where the certificate path is added, everyone celebrates, and nobody ever tightens `ClientAuth` — because at that point the mutual TLS is decorative and every dashboard says it is on.

  • Why does the existing test suite stay green while this bug is live?
    Because every test presents a certificate. A happy-path test behaves identically whether the server requires client certificates or ignores them entirely, so it cannot distinguish the two configurations. Only a negative case — the same request without a certificate, asserted to fail — has any power here, and it is the case teams routinely leave unwritten.
  • The mode is RequireAnyClientCert and the handler reads PeerCertificates. What is wrong now?
    A certificate is required but never verified against `ClientCAs`, so anyone can generate one, put any name in its SANs, and be authorised by a handler that reads the presented chain. `VerifiedChains` stays empty in that mode, which is why reading identity from `VerifiedChains` rather than `PeerCertificates` also defends against this misconfiguration.
  • During a migration off shared secrets, is VerifyClientCertIfGiven ever acceptable?
    As a waypoint, yes, provided a missing certificate falls through to the old credential and is recorded, never skipped. The failure mode is the migration that stops there: certificates flow, dashboards say mutual TLS is on, and nobody tightens ClientAuth, so the guarantee is decorative. Put the tightening date and the metric of remaining non-certificate callers in the plan.
  • How do you tell from the server side that unauthenticated attempts are being rejected?
    Rejections happen in the handshake, so they never appear as HTTP status codes. They surface as `http: TLS handshake error from ...` lines written through `http.Server.ErrorLog`. If you attach a logger there and see no such lines at all while probing without a certificate, nothing is being enforced — the absence of handshake errors is the signal.

A badge reader wired to check anyone who taps a badge, on a door that swings open for anyone who does not tap one. The check is real, thorough and completely optional.

saying these in an interview costs you the question

  • Says the code is fine because valid certificates are checked
  • Blames the client for not sending a certificate
  • Fixes only the handler and leaves ClientAuth unset
  • Believes a populated peer chain implies a verified one
  • Adds a happy-path test and calls the fix verified