Blog / Go

  • go
  • crypto-tls
  • tls
  • session-tickets
  • forward-secrecy
  • security

Go's crypto/tls Session Tickets: Rotating Keys for Forward Secrecy

Forward secrecy is the property where a key stolen tomorrow does not decrypt traffic recorded today. You get it by using ephemeral ECDHE for every handshake and throwing the ephemeral keys away. Most people stop reading there and tick the box.

Session tickets are the quiet exception. A ticket is the server handing the client an encrypted copy of the session state, so that the next connection can skip most of the handshake. The server encrypts that blob with a long-lived symmetric key. If that key outlives the sessions it protects, you have a second, much less glamorous key that can undo the forward secrecy you paid for.

Go handles this for you by default, sort of. Whether the default is good enough depends on how long you are willing to leave a key lying around in memory, and I think most services should pick a number on purpose rather than inherit one.

What the ticket key actually protects

It matters which TLS version you are talking about, because the damage differs a lot.

In TLS 1.2, the ticket contains the session's master secret. Resuming a session does not do a new key exchange: the new traffic keys come from that old master secret plus fresh random values. So an attacker who recorded the handshake (the ticket travels in the clear, encrypted only under the ticket key) and later obtains the ticket key can decrypt the ticket, recover the master secret, and read the original connection and every connection resumed from it. ECDHE did nothing for you here.

TLS 1.3 is better. Go's server only accepts the psk_dhe_ke mode (I checked handshake_server_tls13.go; a client that does not offer it simply gets a full handshake), which means every resumption mixes in a fresh ECDHE secret. A stolen ticket key does not let anyone passively decrypt resumed traffic. Go also only does 0-RTT early data for QUIC, not over TCP, so there is no early-data path to worry about either.

That is not the same as "nothing leaks". The key still opens every ticket it encrypted, and a ticket carries session state: negotiated parameters, and for mutual TLS the client's certificate chain. And plenty of deployments still negotiate TLS 1.2 with older clients. So the rule of thumb is: TLS 1.3 turns a key leak from a disaster into an annoyance, and TLS 1.2 leaves it a disaster.

What Go does if you do nothing

Leave tls.Config.SessionTicketKey at zero and the server generates random keys itself. From the source in common.go: a new key is minted every 24 hours, and an old one is kept for decryption until it is 7 days old. That is the whole policy.

So with the defaults, a single key stays valid for tickets issued up to a day after it was created and stays decryptable for a week. If someone lifts the process memory on day six, they can open tickets from most of the last week. It is not a bad default (a fresh random key per process, never written to disk, is a lot better than what many stacks ship), but the seven days is a compromise for load balancers and flaky clients, not a security number.

Notice also that the field is marked deprecated. The replacement is SetSessionTicketKeys, which is where you take control.

Rotating on your own schedule

Config.SetSessionTicketKeys(keys [][32]byte) takes a list. The first key encrypts new tickets; every key in the list can decrypt old ones. It is safe to call while the server is handling handshakes (it takes the config's mutex), and it panics on an empty slice, so never hand it one. Rotation is then just: generate a key, put it at the front, drop whatever falls off the end.

package main

import (
	"context"
	"crypto/rand"
	"crypto/tls"
	"log"
	"sync"
	"time"
)

// Rotator keeps the newest ticket key first and forgets old ones.
type Rotator struct {
	mu   sync.Mutex
	cfg  *tls.Config
	keys [][32]byte
	keep int
}

func NewRotator(cfg *tls.Config, keep int) (*Rotator, error) {
	r := &Rotator{cfg: cfg, keep: keep}
	return r, r.Rotate()
}

func (r *Rotator) Rotate() error {
	var k [32]byte
	if _, err := rand.Read(k[:]); err != nil {
		return err
	}
	r.mu.Lock()
	defer r.mu.Unlock()
	r.keys = append([][32]byte{k}, r.keys...)
	for i := r.keep; i < len(r.keys); i++ {
		r.keys[i] = [32]byte{}
	}
	if len(r.keys) > r.keep {
		r.keys = r.keys[:r.keep]
	}
	r.cfg.SetSessionTicketKeys(r.keys)
	return nil
}

func (r *Rotator) Run(ctx context.Context, every time.Duration) {
	t := time.NewTicker(every)
	defer t.Stop()
	for {
		select {
		case <-ctx.Done():
			return
		case <-t.C:
			if err := r.Rotate(); err != nil {
				log.Printf("ticket key rotation failed: %v", err)
			}
		}
	}
}

Call NewRotator(cfg, 2) before you start listening, then go r.Run(ctx, time.Hour). With hourly rotation and two keys retained, a ticket is usable for between one and two hours, and no key exists in memory for longer than two.

Checking that it does what you think

I did not want to take my own rotation logic on trust, so I ran it against a throwaway self-signed certificate on loopback, with a client that has a ClientSessionCache and reads ConnectionState().DidResume after each round trip. One detail that caught me out: in TLS 1.3 the ticket arrives after the handshake, so the client has to do a read before the ticket lands in its cache. Write a byte, read a byte, then close.

first: false
second: true
after 1 rotation: true
after 3 rotations (original key gone): false

That is the behaviour you want. The first connection is a full handshake. The second resumes. After one rotation the old key is still in the list, so resumption still works, and the server hands out a new ticket under the new key. After more rotations than the server retains, the client offers a ticket nobody can decrypt, and quietly falls back to a full handshake. No error, no failed connection, just a slightly slower one. That last part is the reason aggressive rotation is cheap: an expired ticket costs one full handshake, not an outage.

The multi-server problem

Here is where it stops being tidy. Behind a load balancer, each instance of the automatic scheme generates its own keys, so a ticket issued by instance A means nothing to instance B. Resumption then only works when a client happens to land on the same instance. That is safe, just wasteful.

The usual fix is to give every instance the same keys, which is exactly what SetSessionTicketKeys exists for. It is also where people go wrong. If the shared keys come from a config file, a Kubernetes Secret baked into an image, or an environment variable that lives for the life of the deployment, you have built a long-lived key and the rotation is theatre. Two rules that I would follow:

  • Keys should be generated centrally (or on one elected instance) and pushed to the others over an authenticated, encrypted channel, then held in memory only. Never write them to disk, and be wary of swap.
  • Do not derive keys from a master secret and a timestamp so that every instance can compute them independently. It is tempting, because it needs no coordination, but the master secret becomes the long-lived key again, and anyone who has it can recompute every past ticket key.

Also note that sharing keys widens the blast radius: one compromised instance now holds keys that open tickets from all of them. Whether that trade is worth the resumption hit rate is a judgement call. For a small service, per-instance keys and accepting some full handshakes is a perfectly respectable answer.

Go 1.21 and custom ticket encryption

Since Go 1.21, Config.WrapSession and Config.UnwrapSession let you replace the ticket format entirely, and EncryptTicket / DecryptTicket give you the stock implementation to call from inside them. That is the hook for wrapping tickets under a key held in a KMS or HSM. It is more machinery than most servers need, and it adds a network round trip to a path whose whole purpose is being fast, so I would only reach for it if your threat model says the ticket key must never be in the process at all.

The pragmatic version: if you terminate TLS 1.3 only, the default is fine and you can stop. If you still accept TLS 1.2, or you run mutual TLS and care about who is in those tickets, pick a rotation interval measured in hours, keep two keys, and keep them out of anything that gets written down. The crypto/tls documentation is short and worth reading once for the exact semantics, and RFC 8446 section 2.2 explains why the DHE resumption mode exists in the first place.