skip to content

How do you read the client certificate identity from r.TLS inside a Go HTTP handler?

level: middleimportance: should knowfreq 36%

answer

  1. the request carries the handshake with it
  2. nil on a plaintext connection
  3. leaf first, chain after
  4. verified is a different field from presented
  5. names live in the SANs, not the CN

basics

~20 s

In a handler, r.TLS is a *tls.ConnectionState and is nil on plaintext connections. The client's own certificate is r.TLS.PeerCertificates[0], and r.TLS.VerifiedChains is non-empty only when the server actually verified it. Take the name from the certificate's SANs.

solid answer

~40 s

`http.Request.TLS` is a `*tls.ConnectionState`, nil when the request did not arrive over TLS. `r.TLS.PeerCertificates` is the chain the client sent, leaf first, so `PeerCertificates[0]` is the caller's own certificate. `r.TLS.VerifiedChains` is populated only when the server verified that chain against `ClientCAs`, which makes `len(r.TLS.VerifiedChains) > 0` the honest test for an authenticated caller; `VerifiedChains[0][0]` is the verified leaf. Pull the identity from the leaf's subject alternative names — `DNSNames`, or `URIs` if you encode service identity as a URI — rather than from `Subject.CommonName`, which is legacy and unconstrained. Then look that identity up in whatever allow-list the service owns, because chain verification proves the CA issued the certificate, not that this caller may make this call.

code

go · 10 lines
go
func caller(r *http.Request) (string, bool) {
	if r.TLS == nil || len(r.TLS.VerifiedChains) == 0 {
		return "", false
	}
	leaf := r.TLS.VerifiedChains[0][0]
	if len(leaf.DNSNames) == 0 {
		return "", false
	}
	return leaf.DNSNames[0], true
}

go deeper

for a junior

Know that the finished handshake is reachable from the request as r.TLS, that it is nil on plain HTTP, and that the caller's own certificate is the first entry of the peer chain.

for a middle

Be ready to explain the difference between PeerCertificates and VerifiedChains, why the leaf comes first, and why subject alternative names beat CommonName as an identity source.

for a senior

Demonstrate the fail-closed shape: extract the identity, reject when there is none, and authorise the name against a policy. Mention rotation and what belongs in the audit log versus the allow-list.

for a principal

Own the naming scheme itself. The SAN convention becomes the identity vocabulary every service authorises on, so argue for one that survives rotation, re-issuance and service renames.

## Where the identity lives When a request arrives over TLS, `http.Request.TLS` points at a `tls.ConnectionState` describing the finished handshake. Two of its fields matter for mutual authentication: - **`PeerCertificates []*x509.Certificate`** — the chain the peer sent, ordered leaf first. `PeerCertificates[0]` is the caller's own certificate; the entries after it are intermediates on the way to the CA. It is populated whenever the client sent anything at all, verified or not. - **`VerifiedChains [][]*x509.Certificate`** — the chains the server successfully built to a certificate in `ClientCAs`. Each inner slice runs leaf-first to root, so `VerifiedChains[0][0]` is the verified leaf. This field stays empty unless verification actually ran and succeeded. That difference is the whole reason to prefer `VerifiedChains` in handler code. `PeerCertificates` being non-empty means only *something arrived*; `VerifiedChains` being non-empty means *the server checked it*. ## Getting a name out of the leaf An `*x509.Certificate` carries several places a name could live. For a service identity, prefer the subject alternative names: - `DNSNames []string` — the usual choice for `billing.internal` style service names. - `URIs []*url.URL` — used when identity is encoded as a URI. - `EmailAddresses`, `IPAddresses` — occasionally used for human or host identities. `Subject.CommonName` is the field everyone reaches for first and the one to avoid: it is a free-text field with no structure, modern issuance practice does not constrain it, and code that authorises on CN is authorising on a string nobody validated. `SerialNumber` identifies the *certificate*, not the caller, so it changes on every rotation and is the wrong key for an allow-list, though it is exactly right for an audit log. ## Turning it into a decision The natural shape in a hand-written RPC server is a small pure function that maps a request to a caller identity and a boolean, followed by a lookup in a map of permitted callers. Keeping the extraction separate from the policy has two payoffs: it is unit-testable without a TLS stack, and the policy map becomes the single place a reviewer can read to answer 'who may call this service'. A caller identity used as a map key also makes per-caller metrics and rate limits fall out almost for free. The critical property of that function is that a missing identity must be a rejection, not a skipped check. Writing the guard as 'if there is a verified chain, check the allow-list' means a connection with no certificate is silently allowed through; writing it as 'get the identity, and reject when there is not one' fails closed. ## Things that make r.TLS nil or empty - The listener is plain HTTP. `r.TLS` is nil, and any code that dereferences it panics — so the nil check comes first. - The service sits behind a proxy that terminates TLS. Then the mutual handshake happened at the proxy, not here, and `r.TLS` describes either nothing or the hop between proxy and service. Whatever identity the proxy extracted arrives, if at all, as a header — and a header is only trustworthy if this listener is unreachable except through that proxy. - The server's `ClientAuth` never asked for a certificate. Then `PeerCertificates` is empty and so is `VerifiedChains`. ## Rotation, expiry and logging Certificates rotate. Because the leaf is per-connection state, a long-lived keep-alive connection keeps presenting the identity it handshaked with; a rotated certificate takes effect on the next handshake. That is fine for an allow-list keyed on the SAN, since the name survives rotation, and it is exactly why keying on `SerialNumber` or on the raw bytes hurts. For audit trails it is worth logging both the stable identity you authorised on and the certificate's serial and `NotAfter`. When someone later asks which credential made a call, the SAN alone cannot answer it; when someone asks whether a caller is about to break, `NotAfter` can. ## The part people skip None of this is authorisation on its own. The chain verified means one of your CAs issued the certificate. In an internal estate where the same CA issues to twenty services, that is a fence around the estate, not around this endpoint. The allow-list — or a per-method policy keyed on the same identity — is what turns a verified caller into a permitted one.

  • Why check VerifiedChains rather than PeerCertificates when deciding whether the caller is authenticated?
    `PeerCertificates` holds whatever the client sent, verified or not — under `RequestClientCert` or `RequireAnyClientCert` it can be populated with a certificate no CA of yours ever signed. `VerifiedChains` is non-empty only when the server built a chain to something in `ClientCAs`. Reading identity from a presented-but-unverified certificate is trusting a string the caller chose.
  • What breaks if you key your allow-list on the certificate's serial number instead of a SAN?
    Every rotation breaks it. `SerialNumber` identifies one issued certificate, so a renewed credential for the same service is a different key and the caller is locked out until someone edits the list. Serials belong in the audit log, where you want to know exactly which credential made a call, not in the authorisation map.
  • Your handler reads r.TLS and gets nil in production but not in tests. What is the likely cause?
    The service is not terminating TLS itself. Something in front — a load balancer or sidecar proxy — completed the handshake and forwarded plaintext, so this listener sees no connection state. Any caller identity now arrives as a header, which is only trustworthy if the listener cannot be reached by anything but that proxy.

saying these in an interview costs you the question

  • Dereferences r.TLS without a nil check
  • Authorises on Subject.CommonName instead of the SANs
  • Treats PeerCertificates being non-empty as proof of verification
  • Assumes the last certificate in the chain is the caller
  • Keys the allow-list on the certificate serial number