All posts

Session Key Rotation: Why Valid Sessions Stop Working

A walkthrough of session fingerprints, rolling deployments, and the compatibility rule that connects them.

Changing a secret sounds like a configuration task. Generate a new value, update the secret manager, deploy, move on.

The part I want to unpack is how that security-maintenance task can turn into an authentication outage.

Imagine a team running a business dashboard across several API servers. Its security policy calls for a routine change to the key used to fingerprint session tokens. There’s no reason to interrupt users, so the team schedules a rolling deployment: replace servers gradually while the others keep serving requests.

Alice is already signed in and reviewing orders. After the rollout starts, one request succeeds. A moment later, another request treats her as logged out. Refreshing appears to fix it.

Her browser still sends the same token. The session has not expired. The database is available.

So what changed?

For Alice, the dashboard feels unreliable. Support gets a report that is hard to reproduce. Meanwhile, both server versions can pass their own login tests: the failure depends on which server handles the request and what another server has already written.

I’ll reduce this hypothetical deployment to two servers so we can follow the failure without hiding it behind infrastructure.

What are we actually trying to protect?

Not the old key. We want to replace it.

What I want to preserve is a valid user’s ability to keep working during routine rotation. At the same time, the system needs a path to stop depending on the old key.

Those goals pull in different directions:

  • Remove the old key immediately, and existing sessions may become unreadable.
  • Keep it forever, and we never finish the rotation.
  • Allow new fingerprints before all servers understand them, and authentication can depend on request routing.

The useful question isn’t just “How do I generate another secret?” It’s “How do I move existing credentials to it without breaking readers along the way?”

We’ll work from the session lookup to the deployment order, then test the boundary that makes the rollout safe. The same reasoning applies whenever old and new application versions share changing data.

The key helps us find the session

First, a boundary: I’m discussing opaque, database-backed sessions, not signed JWTs.

The browser holds a random token. The server stores a keyed fingerprint of it, calculated with HMAC. HMAC combines the token and a server-held secret into a deterministic value: the same token and key produce the same fingerprint.

Here’s the simplified model:

Browser holds:  raw token
Database holds: HMAC(secret key, raw token) → user, expiry

On each request, the server calculates the fingerprint again and searches for the matching, unexpired session.

Why not store the raw token? A database-only leak should not hand an attacker ready-to-use session credentials. Fingerprinting keeps the stored lookup value separate from the credential the browser presents. It does not make a stolen browser token harmless.

Now the catch: the secret key is part of the lookup.

HMAC(key A, Alice's token) → fingerprint A
HMAC(key B, Alice's token) → fingerprint B

Changing the key changes the fingerprint. If the database still contains fingerprint A and the server searches only for fingerprint B, it finds nothing.

Alice didn’t lose her session. We lost the ability to look it up.

Keep the old key readable; write with the new one

A way to preserve existing sessions is to retain both keys during the transition.

When Alice’s token arrives, calculate both candidate fingerprints. Search for either one. If a valid session matches the old fingerprint, replace that fingerprint with the new one.

The browser token stays exactly the same.

flowchart TD Token["Same browser token"] --> Old["Calculate fingerprint with old key"] Token --> New["Calculate fingerprint with new key"] Old --> Lookup["Find an unexpired session matching either candidate"] New --> Lookup Lookup --> Found{"Session found?"} Found -->|No| Reject["No authenticated session"] Found -->|Yes| Current{"Stored fingerprint already current?"} Current -->|Yes| Keep["Return session unchanged"] Current -->|No| Update["Replace stored fingerprint with new-key candidate"] Update --> Return["Return session; transaction must still commit"]

The important ordering: find a valid session first, then migrate its fingerprint. Calculating a new fingerprint doesn’t prove a session exists.

In simplified pseudocode:

candidates = fingerprints_for_retained_keys(raw_token)
session = find_unexpired_session(candidates.values())

if session is None:
    return None

current_fingerprint = candidates[current_key_id]
if session.fingerprint != current_fingerprint:
    session.fingerprint = current_fingerprint

return session

That assignment is not a commit. The surrounding transaction still has to persist the change.

To make fingerprints_for_retained_keys() concrete, here’s an illustrative Python implementation. This is the fingerprinting step, not a complete authentication system:

import hashlib
import hmac


def fingerprints_for_retained_keys(
    raw_token: str, keys: dict[str, bytes]
) -> dict[str, str]:
    return {
        key_id: f"{key_id}:{hmac.new(key, raw_token.encode(), hashlib.sha256).hexdigest()}"
        for key_id, key in keys.items()
    }

keys contains retained secret values loaded from configuration or a secret manager. current_key_id separately identifies which key to use for new writes; dictionary order doesn’t choose it. The prefix identifies the fingerprint version, not the secret itself.

I like this approach because it migrates sessions when their tokens naturally arrive. We don’t need to ask every user to sign in again. And already-current sessions don’t need a migration write.

But that last detail hides a deployment problem: we’re changing shared database state while multiple application versions may still be running.

One server upgrades the record; another can no longer read it

Suppose the rollout looks like this:

ServerKeys it can readKey it uses for writes
AOld key onlyOld key
BOld and new keysNew key

Alice reaches B first. B finds her session using the old fingerprint and commits its migration to the new fingerprint.

Her next request reaches A.

sequenceDiagram participant Browser as Alice's browser participant B as Server B: both keys participant DB as Session database participant A as Server A: old key only Browser->>B: Send session token B->>DB: Search old + new fingerprints DB-->>B: Match old fingerprint B->>DB: Commit new fingerprint B-->>Browser: Authenticated request succeeds Browser->>A: Send the same token A->>DB: Search old fingerprint only DB-->>A: No match

A can’t calculate the fingerprint now stored in the database. Depending on the endpoint, Alice might see an authentication error or be redirected to sign in.

The load balancer didn’t damage anything. It exposed incompatible readers.

This is why testing each version alone isn’t enough. B works with old records. A works with old records. What fails is A reading a record after B changes it.

The deployment has two separate milestones

My rollout plan would separate two capabilities:

  1. Every server can read the new fingerprint format.
  2. Servers may write the new fingerprint format.

I’d prepare all readers first: deploy both keys everywhere, while keeping the old key designated as current. Only after confirming reader readiness would I enable new-key writes.

flowchart TD Start["1. Starting point
Read: A · Write: A"] Prepare["2. Prepare every reader
Read: A + B · Write: A"] Ready{"All possible readers support B?
Serving replicas, autoscaling, rollback"} Wait["Keep A current
Finish reader preparation"] Activate["3. Activate B across writers
Read: A + B · Target write key: B"] Retire{"Any valid credentials still require A?"} Retain["Keep A readable
Migrate, wait for expiry, or invalidate"] Finish["4. Retire A
Read: B · Write: B"] Start --> Prepare --> Ready Ready -->|No| Wait --> Ready Ready -->|Yes| Activate --> Retire Retire -->|Yes| Retain --> Retire Retire -->|No| Finish

The two gates answer different questions: can every server read B? and does any valid credential still need A? Passing the first doesn’t imply the second.

During that second phase, servers can temporarily disagree about which key is current without losing read compatibility, provided they all retain both keys. But they may rewrite a session back and forth. I’d finish that transition promptly rather than treat it as a steady state.

The rule is simple:

Before any writer creates a new representation, every reader that might encounter it must understand it.

“Every reader” includes replicas that autoscaling might launch and versions we might restore during rollback.

Updating the secret manager alone doesn’t establish readiness. If processes cache secrets at startup, running instances keep their old view until refreshed or restarted. I’d verify the serving fleet, not just the value in a configuration screen.

Rollback needs the same care. After new fingerprints are committed, restoring an old-only reader is no longer a safe rollback. Reverting code doesn’t revert the data it already wrote.

Quiet users make retirement harder

Lazy migration updates active sessions. It does nothing for someone who doesn’t visit during the transition.

Alice might migrate today. Bob might return next month with an unexpired session that still uses the old key.

That’s why “the new deployment has been healthy for a day” isn’t enough evidence to delete the old key. I’d need to account for remaining valid credentials: have they migrated, expired, or been deliberately invalidated?

If several credential types share the key set, I’d include all of them. An empty count of old user sessions doesn’t prove that old API credentials are gone.

Could I migrate everything in a background job instead? Not from these fingerprints alone. Calculating the new fingerprint requires the original token. Hashing an old fingerprint with the new key doesn’t produce the fingerprint of the browser’s token.

The simpler alternative is forced reauthentication. That’s a legitimate choice when the interruption is acceptable. It trades migration complexity for user disruption. I wouldn’t build a compatibility mechanism simply because it looks more sophisticated.

What gets expensive and what doesn’t

Retaining more keys means calculating more fingerprints per lookup and checking more candidate values in the query. It doesn’t require a separate database round trip for every key.

Migration adds another cost: writes. During the transition, active old sessions generate updates. Concurrent requests can add contention. Once a session is current, this mechanism stops needing to update its fingerprint.

I wouldn’t guess which cost dominates. I’d inspect lookup latency, query plans, database writes, and connection-pool waits under representative traffic. Increasing API replicas doesn’t fix a saturated database; it can give that database more concurrent work.

A slow database also isn’t the same as an invalid token. “The query found no record” and “we couldn’t complete the query” are different outcomes. Silently treating both as a bad login hides an availability problem inside an authentication symptom.

There is one reassuring property if a response disappears after migration: the browser token hasn’t changed. If the transaction committed, a compatible server can match the new fingerprint on retry. If it rolled back, the old fingerprint remains available while the old key is retained.

That doesn’t make the whole endpoint safe to retry. Authentication might precede a payment or another mutation with its own duplicate-request risks.

Rotation is not revocation

If someone steals Alice’s raw token, moving its database fingerprint to a new key doesn’t stop that token from working. Preserving the token is exactly what this migration is designed to do.

A stolen token needs revocation. A compromised fingerprinting key needs an investigation into the exposure and a containment decision; knowing that key alone isn’t the same as knowing a valid random browser token.

Routine rotation asks, “How do we preserve legitimate sessions while changing keys?” A breach may require a different question: “Which credentials should no longer work?”

I wouldn’t let a smooth migration plan substitute for that decision.

Test the boundary, not just the happy path

The smallest useful compatibility test needs one database and two readers with different key configurations. The crucial assertion isn’t just that the new server accepts an old session. It’s whether another server can still read that session after its fingerprint changes.

  1. Create a session with key A.
  2. Authenticate through a reader that knows A and B, with B current. Commit the migration.
  3. Present the same token to an A-only reader. Assert that it cannot find the migrated session. This captures the unsafe rollout.
  4. Repeat with both readers supporting A and B before migration. Assert that both can find the session afterward. This captures the compatibility requirement.
  5. Leave another A-session unused, then remove A. Assert that the unmigrated session no longer matches. This captures premature retirement.

Inspect the committed record, not just the returned object. Also check that the raw token never changes and that an expired session stays rejected regardless of which key matches.

These tests target data compatibility. Throughput needs a load test; rollout order needs a deployment check. A passing session test cannot answer all three questions.

The lesson I want to keep

The interesting part of key rotation isn’t generating another secret. It’s coordinating a change in how shared data is represented.

The same question turns up in schema migrations, event formats, and cache entries:

Can yesterday’s reader still understand what today’s writer is about to save?

That’s the question I’d ask before calling a secret change “just configuration.”

Enlarged diagram