Backend

How to Rotate JWT Signing Keys Without Logging Everyone Out

Rotating a JWT signing key naively invalidates every live token and logs out all users. Here is the overlap window and kid design that avoids it.

A JWT is trusted because it was signed with a key only your server holds. If that key leaks, anyone can forge a valid token, so you have to change the key on a schedule. The naive way, swap the old key for a new one, breaks every token that is still in flight, and every logged-in user gets kicked out the next time they make a request. This post shows the design that rotates the key with zero forced logouts: keep several keys alive at once, sign with the newest, and verify against all of them until the old tokens expire on their own.

The core idea is small. Signing and verification do not have to use the same single key. You sign with exactly one key, the current one, but you verify against a set. As long as a token was signed by any key still in that set, it stays valid. Rotation then becomes a matter of adding a key to the set, moving the signer to it, and removing the old key only after nothing signed with it can still be alive.

Why a naive key swap logs everyone out

Say you issue tokens with a 24 hour lifetime. At any moment you have a rolling population of valid tokens, some issued seconds ago, some issued almost 24 hours ago. All of them were signed with the key that was current when they were issued.

Now you replace the key. The next request arrives carrying a token signed with the old key, your server tries to verify it with the new key, the signature does not match, and verification fails. The user looks logged out. This happens to every token issued before the swap, which is your entire active user base. You did not revoke anyone on purpose. You just changed the one secret that made their tokens verifiable, and there was no path back.

The lifetime of the token is the crux. A token signed by the old key can remain valid for up to its full lifetime after you swap. So the old key has to stay usable for verification for at least that long. That single observation is the whole design.

The fix: sign with one key, verify against many

Split the key into two roles.

  • Signing uses one key, the current key. Every new token is signed with it.
  • Verification uses a key set, the current key plus any older keys whose tokens might still be alive.

When a token comes in, you do not assume it was signed by the current key. You figure out which key signed it and verify against that key. If the signing key is still in your set, the token is good. If it was signed by a key you have already retired, verification fails, which is exactly what you want for a key that is genuinely gone.

To make step one cheap, every key gets a short identifier called a kid (key ID). When you sign, you stamp the kid into the token header. When you verify, you read the kid from the header and look up that exact key. No kid means you would have to try every key in your set until one works, which is slower and leaks a little timing information. With a kid, verification key lookup is a single map read.

Here is the header of a JWT with a kid. The header is just base64url encoded JSON, so it is readable before any signature check:

{
  "alg": "HS256",
  "kid": "2026-07-01"
}

The kid can be anything unique and stable per key. A date, a random string, or a counter all work. The only rule is that it must not collide with another live key, and it must not carry a secret, since anyone can read it.

The rotation timeline, step by step

Rotation is a sequence of states, not a single flip. Each state is safe to sit in, which means you can automate the transitions on a timer and never coordinate a synchronized restart.

Phase Signing key Verification set What is happening
Steady K1 {K1} Only one key exists, all tokens signed by K1
Introduce K1 {K1, K2} K2 is added to the set but nothing signs with it yet
Cut over K2 {K1, K2} New tokens use K2, old K1 tokens still verify
Overlap K2 {K1, K2} Wait for the last K1 token to expire
Retire K2 {K2} K1 removed, its tokens are all gone anyway

The one duration that matters is the overlap. K1 must stay in the verification set for at least as long as the maximum token lifetime, counted from the moment you stopped signing with it. If tokens live 24 hours and you cut over to K2 at noon, the newest possible K1 token was minted just before noon and dies just before noon the next day. Remove K1 any earlier and you invalidate tokens that were legitimately issued. Remove it after that window and no live token depends on it, so retirement is a no-op for users.

Notice that at no point does the verification set shrink below what live tokens need. That is what keeps the rotation invisible from the user side. They never see a signature failure caused by rotation, only by a token that was already expired.

A minimal Go implementation

Here is a keyring that holds several keys, signs with the current one, and verifies by kid. It uses HMAC (HS256) for brevity, but the same shape works for asymmetric keys (RS256, ES256), where the verification set holds public keys and only the signer holds the private key.

package auth

import (
	"errors"
	"time"

	"github.com/golang-jwt/jwt/v5"
)

type key struct {
	id       string
	secret   []byte
	notAfter time.Time // stop using for verification after this
}

type Keyring struct {
	current string          // kid of the signing key
	keys    map[string]*key // kid -> key, the verification set
}

func (r *Keyring) Sign(claims jwt.MapClaims) (string, error) {
	k := r.keys[r.current]
	tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
	tok.Header["kid"] = k.id
	return tok.SignedString(k.secret)
}

func (r *Keyring) Verify(raw string) (jwt.MapClaims, error) {
	claims := jwt.MapClaims{}
	_, err := jwt.ParseWithClaims(raw, claims, func(t *jwt.Token) (any, error) {
		kid, ok := t.Header["kid"].(string)
		if !ok {
			return nil, errors.New("token has no kid")
		}
		k, ok := r.keys[kid]
		if !ok {
			return nil, errors.New("unknown or retired kid")
		}
		if time.Now().After(k.notAfter) {
			return nil, errors.New("key past its verification window")
		}
		return k.secret, nil
	}, jwt.WithValidMethods([]string{"HS256"}))
	return claims, err
}

Two details carry real weight. The jwt.WithValidMethods option pins the accepted algorithm, which closes the classic alg confusion attack where a caller sets alg to none or downgrades RS256 to HS256. And the kid lookup returns an error for an unknown or retired key rather than falling back to a default, so a token signed by a key you deliberately removed fails closed.

Rotation itself is a small mutation on the same structure. A scheduled job adds the next key, flips the signer, and prunes anything past its window:

func (r *Keyring) Rotate(newKid string, secret []byte, tokenTTL time.Duration) {
	// New key can verify tokens minted from now until now + tokenTTL,
	// plus a margin so the last-minted token is safely covered.
	r.keys[newKid] = &key{
		id:       newKid,
		secret:   secret,
		notAfter: time.Now().Add(tokenTTL * 2),
	}
	r.current = newKid // sign with the new key from here on

	// Drop keys whose verification window has passed.
	for kid, k := range r.keys {
		if time.Now().After(k.notAfter) {
			delete(r.keys, kid)
		}
	}
}

Automating rotation across many instances

One process holding a keyring in memory is easy. The real deployment has several server instances behind a load balancer, and they all have to agree on which keys are live. If instance A rotates in a new key and instance B has never heard of it, a token signed by A fails on B, and you are back to random logouts.

The fix is to keep the keyring in shared storage instead of per-process memory. Redis or a database row works. A scheduled job, weekly is common, generates a new key, writes it to the shared store with its kid and its window, and moves the current pointer. Each instance reads the key set from the store, either on a short cache with a TTL or through a pub/sub notification, so within seconds every instance sees the same set.

A practical setup keeps two keys live at all times, current and next, with overlapping windows. The job that runs weekly promotes next to current and mints a fresh next. Because the windows overlap by more than a token lifetime, there is never a gap where an instance would reject a valid token. If you already run a Redis for sessions or rate limiting, this adds one small key and one cron-like job, nothing heavier.

For asymmetric keys there is a well trodden standard for exposing the verification set: JWKS, a JSON document at a known URL listing your public keys by kid. Resource servers fetch it, cache it, and match the kid in an incoming token to a public key. The kid in your token header is the same kid that indexes the JWKS entry, which is why the identifier is worth adding even in a single-service setup. It is the seam that lets other services verify your tokens without holding your signing key.

The tradeoffs you are accepting

This design buys zero-downtime rotation, and it is not free.

You now manage a set of keys instead of one: generating them, storing them securely, syncing them across instances, and pruning them on time. A bug in the pruning logic either keeps dead keys around (mild) or removes a key too early (a logout event). The shared store becomes part of your auth critical path, so its availability now affects login.

The overlap window is a security cost as much as a convenience. During overlap the old key is still accepted, so if the reason you are rotating is a suspected leak, the leaked key keeps working for the length of that window. You cannot both honor live tokens and instantly kill a compromised key with this mechanism alone. The lever that shrinks the exposure is a short token lifetime: 15 minutes to an hour for access tokens, refreshed from a longer lived refresh token, means a compromised signing key is useful to an attacker for at most that short window after you cut over. This is the main reason short access-token lifetimes and refresh tokens go together with key rotation.

There is also the stateless versus stateful choice underneath all of this. Verifying a JWT with a key set is stateless: any instance can check any token with only the keys in memory, no database lookup per request, which is the whole appeal of JWTs. The moment you need to revoke a specific token before it expires, for a logout or a compromised account, you need state: a deny list of token IDs, checked on every request. That reintroduces the per-request lookup you were avoiding. Many systems accept a small deny list for the rare forced-revocation case while keeping the common path stateless. Key rotation and per-token revocation solve different problems, and rotation does not give you revocation.

When a single key is enough

Rotation is worth doing for anything that outlives a prototype, but the machinery above can be overkill for small or short-lived systems. A single key with no kid and no keyring is fine when all of the following hold:

  • The signing key lives only in server memory or a secret manager and never transits an insecure channel, so a leak is unlikely.
  • Token lifetimes are short and there is a refresh path, so even a forced rotation that logs everyone out is a brief annoyance rather than an outage.
  • You control every verifier, so there is no external party who needs a stable kid or a JWKS endpoint to keep working through a change.
  • You can tolerate one planned maintenance window to swap the key if it ever comes to that.

For an internal tool with a handful of users, swapping a key during a quiet hour and accepting the re-login costs far less than building and operating a keyring. The point is to know which situation you are in. The moment you have real users, external verifiers, or a compliance requirement to rotate on a fixed schedule, the multi-key design pays for itself the first time you rotate without anyone noticing.

The mechanism reduces to one rule. Sign with the newest key, verify against every key that could still have live tokens, and only retire a key after its longest possible token has expired. Get the overlap window right and rotation stops being an event your users feel. It becomes a job that runs while they stay logged in.

Related posts