Skip to content
Slicekit
All posts
· Slicekit Team

Assume the token is stolen: passkeys, cookies and refresh-token rotation

Start from the worst case, a credential already in the wrong hands, and work backward: passkeys with no stealable secret, HttpOnly cookies XSS cannot read, and family-based refresh-token rotation as a tripwire.

Most threat models start optimistically: keep the secret safe, and you are fine. That is the wrong place to begin, because the secret will not stay safe. Phishing pages collect real passwords, stray scripts read whatever the browser will hand them, and tokens leak in ways you never see. So flip the question. Assume the credential is already stolen, sitting in an attacker’s terminal right now. What in your design actually stops them from using it?

That single question has three good answers, and Slicekit builds all three in. None of them is “try harder to keep the secret.” Each one removes a way the stolen credential can pay off.

Don’t have a stealable secret in the first place

The strongest answer is that there is nothing on the server worth stealing. That is what passkeys buy you. With WebAuthn, the authenticator on the user’s device, Touch ID, Windows Hello, a hardware key, generates a public/private key pair. The private key never leaves the device. The server stores only the public key and a credential id (W3C WebAuthn Level 2).

Run the stolen-credential thought experiment against that. An attacker who exfiltrates the entire credential database gets a pile of public keys, which by definition are not secret and prove nothing on their own. There is no shared secret to phish, because credentials are origin-scoped: a passkey registered for your domain simply will not respond to a lookalike phishing site, which is what makes WebAuthn phishing-resistant rather than phishing-hardened.

Slicekit keeps passwords supported but moves the headline to passkeys, and here is the honest part. Slicekit implements WebAuthn with fido2-net-lib, the established .NET FIDO2 library, which runs the registration and assertion ceremonies and verifies the signature against the stored public key. (ASP.NET Core Identity in .NET 10 now ships its own built-in passkey support, but Slicekit predates and does not use it.) What no library decides for you is which authenticators to trust: attestation-statement validation is a policy you own, and Slicekit ships without an AAGUID allowlist, so any conformant authenticator is accepted. For a typical SaaS that is the right default. If you are in a regulated setting that must restrict sign-in to specific certified authenticators, wiring up attestation verification is work you take on, not something the library does by default. Knowing that boundary up front is the difference between a passkey feature that ships and one that surprises you in an audit.

Don’t let XSS read the token

Passkeys cover sign-in. But the moment the user is authenticated, the browser holds a session credential, and that is the next thing the thought experiment goes after. The tempting place to keep it is localStorage: easy to attach to a fetch call, no CSRF to worry about. The problem is that anything JavaScript can read, an XSS-injected script or a malicious dependency can read too, and walk straight out the door with it.

This is no longer a judgment call. The OWASP Session Management Cheat Sheet now says plainly: do not store auth tokens, JWTs, or refresh tokens in localStorage or sessionStorage. Use HttpOnly, Secure, SameSite cookies, or a backend-for-frontend that holds the tokens server-side.

Slicekit takes the cookie branch: HttpOnly cookie sessions the page’s JavaScript can never read, which closes the exfiltration path entirely. That has a known cost, because cookies ride along automatically, they reintroduce CSRF, so Slicekit pays for it directly. Every state-changing request also carries a CSRF token the frontend echoes back in a header, and the typed API client attaches both the cookie and the header for you. The trade is deliberate: take on CSRF, a solved problem with a clear pattern, in exchange for making a stolen-from-the-browser token impossible rather than merely unlikely.

Make a stolen refresh token a tripwire

Long-lived sessions need refresh tokens, and a refresh token is exactly the long-lived, replayable credential the thought experiment loves. A naive design hands out one static refresh token and trusts it for weeks, so a single theft is weeks of silent access. Cookies stop the browser-side read, but they do not stop a token that leaked through a proxy, a log, or a compromised backend.

Slicekit uses refresh-token rotation with family-based reuse detection. Every use of a refresh token returns a brand new one and invalidates the old. All tokens descended from a single login form a token family (Auth0, OWASP OAuth2 Cheat Sheet). The insight is what a retired token means: a legitimate client always moves forward to its newest token, so if an already-invalidated token comes back, the only explanation is that someone kept a copy. That replay is the theft signal, and reuse detection revokes the whole family on the spot.

Refresh-token family F

token 1

issued at login

token 2

refresh rotates, token 1 retired

token 3

refresh rotates, token 2 retired

replay token 1

a retired token comes back

reuse detected

a retired token can only mean a copy

revoke family F

tokens 1, 2, 3 all dead

A long-lived refresh token is a standing risk: steal it once and it works for weeks. Rotation plus family-wide revocation flips that around, so the first replay of a stale token burns the whole family and the legitimate user just signs in again.

There is a real-world wrinkle, and pretending it away is how this goes wrong. Rotation has to be atomic, because legitimate near-simultaneous refreshes, multiple tabs, a retried request, a mobile client reconnecting, can look identical to a replay. That forces a grace-window tradeoff: too short and you log real users out for opening two tabs, too long and you widen the very window you were trying to close (why rotation alone is not enough). The trade Slicekit makes is a little bookkeeping, a family id, a used flag, a revocation check on every refresh, in exchange for turning a stolen refresh token from a quiet multi-week foothold into a one-shot tripwire.

What this does, and does not, buy you

Be honest about the limits, because the same source that praises rotation is blunt about it: rotation alone does not stop a stolen token. On its own it only shortens the window before reuse detection fires, and it is only as good as the company it keeps. It has to be paired with short access-token lifetimes, so a leaked access token expires fast, and with secure cookie storage, so the refresh token was hard to steal in the first place. Rotation is the tripwire; short lifetimes and HttpOnly cookies are the locked doors that make tripping it rare.

That is the whole point of running the thought experiment three times. Passkeys mean there is often no server-side secret to steal. HttpOnly cookies mean an XSS script cannot read the session. Rotation with family reuse detection means a refresh token that does leak burns itself the second it is replayed. No single mechanism is sufficient, which is exactly why a credible design layers all three instead of betting on one.

For the full picture, including cookie flags, permission checks, admin impersonation and the audit pipeline, read the authentication guide. For the second-factor enrollment and login-challenge flow, see two-factor authentication.