A perfectly random token that stopped nothing
The OAuth state parameter only stops login CSRF when it is bound to the browser that started the flow. Six real CVEs where the value was random anyway.
- security
- vibe coding
- engineering
The best sentence ever written about the OAuth state parameter is in the
immich security advisory for CVE-2025-43856, and it is an aside. The reporter is
explaining their proof of concept, and notes that state=gibberish works fine,
“because the one generated by the immich web app - while being unpredictable, as
it should be - does not do anything.”
Unpredictable, as it should be. And useless.
That is the whole subject. Almost nobody ships an OAuth login with a guessable state value, because every tutorial says to use a random one and every library hands you a random one. What teams ship instead is a random value that is never compared, or compared against something an attacker can also write, or built out of a secret the flow then publishes into the URL. The randomness is the easy half and it is the half everyone gets right.
What login CSRF actually does to you
The confusing thing about this attack is that the victim is not logged into by an attacker. The victim is logged in as the attacker, which sounds like the attacker’s problem until you follow it through.
RFC 6749, section 10.12, states the consequence in one sentence and picks a good example: “A CSRF attack against the client’s redirection URI allows an attacker to inject its own authorization code or access token, which can result in the client using an access token associated with the attacker’s protected resources rather than the victim’s (e.g., save the victim’s bank account information to a protected resource controlled by the attacker).”
So: you believe you are in your own account. You upload a document, connect a payment method, paste an API key, type a private note. All of it lands in an account the attacker can log into whenever they like. It is a data exfiltration primitive that runs on the victim’s own hands, and the victim has no signal that anything is wrong, because the application is behaving perfectly.
immich makes the consequence worse, and its advisory is worth reading in full
because the mechanism is entirely ordinary. immich uses /user-settings as a
redirect URI, and that page “will automatically link the accounts if the user was
already logged in.” The attacker starts an OAuth flow with their own Google
account, blocks the final redirect on their own machine so the callback URL is
never spent, and then delivers that URL to a victim, “hiding them behind url
shorteners” or embedding it “in a (hidden) iframe on a website they control”. A
logged-in victim who loads it links the attacker’s Google identity to the
victim’s immich account. From then on, as the advisory puts it, “the attacker can
log into the victims account using their own oauth credentials.”
That is the reverse of the usual direction. Ordinary login CSRF ends with the
victim’s data inside the attacker’s account. immich’s variant ends with the
attacker’s credential inside the victim’s account, which is takeover rather than
surveillance, and it happens because the callback linked an identity instead of
starting a session. GitHub’s advisory scores it high, 7.3, with base metrics
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:A/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N, and classifies
it CWE-303, incorrect
implementation of an authentication algorithm. It was fixed in 1.132.0.
If your app has a “connect your Google account” or “link GitHub” button anywhere in settings, that is the same shape. The account-linking endpoint is usually written by whoever was adding the integration, months after the login flow was reviewed.
The spec asked for it in the weakest words available
Here is the part that explains why this keeps happening. RFC 6749 defines the parameter in section 4.1.1 like this:
state
RECOMMENDED. An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery as described in Section 10.12.
RECOMMENDED. SHOULD. Meanwhile, section 10.12 of the same document says “The client MUST implement CSRF protection for its redirection URI.”
The obligation is a MUST and the only mechanism offered for it is a SHOULD, in a parameter whose name and one-line description are about carrying application state. A developer reading the parameter list sees an optional field for round-tripping a value. A developer reading page 59 of the same document sees a mandatory security control. Most people read the parameter list.
Section 10.12 also says exactly what the value has to be, and this is the sentence the rest of this post is about: CSRF protection “is typically accomplished by requiring any request sent to the redirection URI endpoint to include a value that binds the request to the user-agent’s authenticated state (e.g., a hash of the session cookie used to authenticate the user-agent).”
Binds the request to the user-agent’s authenticated state. Not “is random.” Not “is unique.” The value has to be an answer to the question did this browser start this flow, and it has to be an answer only that browser could produce.
Four ways to have a state parameter and still lose
Each of these is a shipped CVE in software people run in production, and none of them is a randomness failure.
One: generated and never checked. immich, above. The web app minted a proper unpredictable value, sent it, and the callback handler did not compare it to anything. This is the most common form by a distance, because a value that is generated is visible in the code review and a value that is never read is not.
Two: generated once, for everybody. @fastify/oauth2,
CVE-2023-31999: “All versions
of @fastify/oauth2 used a statically generated state parameter at startup time
and were used across all requests for all users.” The advisory then states the
rule the library had broken, which is the same rule RFC 6749 states: “it should
be unique per user and should be connected to the user’s session in some way that
will allow the server to validate it.” NVD scores it 8.8 high
(CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H); GitHub rated the same bug
medium, which tells you something about how consistently this class gets triaged.
Note that the check here was not missing. The comparison ran, and passed, for
every attacker, forever.
Three: carrying a URL instead of a token. Redash,
CVE-2021-43777, whose
advisory
says it plainly: the Google login implementation “incorrectly uses the state
parameter to pass the next URL to redirect the user to after login. The state
parameter should be used for a CSRF token, not a static and easily predicted
value.” One field, two jobs, and the job the field was named for won. NVD
classifies it
CWE-601, open redirect, while
GitHub classifies it CWE-352, cross-site request forgery, and both are right:
putting a destination in state gives you an
open redirect and removes your CSRF defense in
a single design decision. Redash’s fix is the one to copy: they moved to a
library that generates and validates the token, and, in the advisory’s words,
“The new implementation stores the next URL on the user session object.”
Four: reusing a secret you already had. Two flavors, both from 2026.
MISP’s Azure AD authentication plugin,
CVE-2026-56425, used the PHP
session identifier as the state value. The
fix commit
deletes one line, $url .= "state=" . session_id();, and its replacement carries
a comment that is better security writing than most security writing. The state
“MUST NOT be the session ID (or derived from it)” because “the session ID is
itself a long-lived credential,” and the comment then lists where putting it in
this URL would expose it: “browser history, Referer headers, and access/proxy
logs along the redirect chain to and from Azure AD”. A control that was supposed
to protect the callback was instead publishing a
session cookie into every log on the
route. NVD scores it 8.8 high on CVSS 3.1; the assigning authority scored it 9.3
critical on CVSS 4.0, which are different scales rather than a disagreement.
Notice what this case is not. The line that was deleted from the callback,
} elseif (strcmp(session_id(), $_GET["state"]) == 0) {, does bind the response
to the browser’s own session, which is the shape RFC 6749 asks for. The binding
worked. What failed is the value it was built from: a credential the same design
was publishing into logs, and one the CVE notes was not single-use either, which
“weakened CSRF protections and increased the risk of replay attacks against the
OAuth callback process.”
OpenClaw, CVE-2026-34511, did
the 2026 version of the same mistake: its Gemini OAuth flow “reused the PKCE
verifier as the OAuth state value. Because the provider reflected state back
in the redirect URL, the verifier could be exposed alongside the authorization
code.” PKCE exists so that capturing the code is not enough. Putting the verifier
in a parameter designed to be echoed back means capturing the code is enough.
Here too the verifier was a fine binding, held per flow by the client that
started it. The advisory’s one-line description of the fix commit is the whole
lesson in seven words: “separate OAuth state from the PKCE verifier”.
The fix for the 2023 bug became the 2026 bug
@fastify/oauth2 fixed CVE-2023-31999 in v7.2.0 by doing the obvious thing:
“v7.2.0 changes the default behavior to store the state in a cookie with the
http-only and same-site=lax attributes set. The state is now by default
generated for every user.” Per-user, unpredictable, stored where the browser
keeps it, compared on the way back. That is the textbook implementation.
On 2026-08-14 the same library published CVE-2026-18165, “Login CSRF via plantable OAuth state cookies”, against exactly that fix.
The mechanism is worth quoting at length, because its last sentence is the general statement of this entire post:
@fastify/oauth2validates the OAuthstateby comparing the callbackstatequery parameter against a cookie it set at the start of the flow (and, with PKCE, thecode_verifierfrom a second cookie). Both cookies use unprefixed names (oauth2-redirect-state,oauth2-code-verifier), so any party that can write a cookie for the application’s host can supply both values. The check only proves the query and cookie match, not that the same browser began the flow, so such a party can complete their own authorization flow inside a victim’s browser.
The check only proves the query and cookie match, not that the same browser began the flow. Two random values that agree with each other prove agreement, not origin.
Who can write a cookie for your host? The advisory lists it: applications are affected when “some host under their registrable domain is not fully under their control (for example a subdomain with an XSS, a subdomain takeover or dangling DNS record, or a forgotten staging host)”. The reason it matters is the sentence after it: “Cookies are scoped by host, not origin, and carry no integrity against related hosts (RFC 6265).” That is the same dangling-CNAME inventory that turns a wildcard entry in a redirect URI allowlist into an attacker-controlled callback. One forgotten subdomain, two unrelated-looking bugs.
Two more details from that advisory deserve to be read carefully. First:
“Enabling PKCE does not help, because the verifier is read from a second cookie
that can be planted the same way.” Second, the mitigation shipped in 8.3.0 is an
opt-in flag, hostPrefixedCookies: true, and “The flag is off by default, so
upgrading alone does not change behavior.” A dependency bump does not fix this
one. Somebody has to read the release notes, which is a good argument for
treating advisories on your auth libraries as work rather than as noise.
The advisory’s own preferred answer is the one this post ends up at: “The most
robust option is to bind the state to the browser session server-side with
generateStateFunction and checkStateFunction”.
Can you just use PKCE and skip state?
This is the modern version of the question, and the honest answer is yes, with conditions that are easy to miss.
RFC 9700, the current OAuth 2.0 security best current practice, section 2.1: “Clients MUST prevent Cross-Site Request Forgery (CSRF)… Clients that have ensured that the authorization server supports Proof Key for Code Exchange (PKCE) [RFC7636] MAY rely on the CSRF protection provided by PKCE. In OpenID Connect flows, the nonce parameter provides CSRF protection. Otherwise, one-time use CSRF tokens carried in the state parameter that are securely bound to the user agent MUST be used for CSRF protection (see Section 4.7.1).”
Read the conditions in order. The client must have ensured the server supports
PKCE, which section 4.7.1 repeats as a MUST with a fallback: “Clients MUST ensure
that the authorization server supports PKCE before using PKCE for CSRF
protection. If an authorization server does not support PKCE, state or nonce MUST
be used for CSRF protection.” And if you keep state around for application
data, 4.7.1 adds an obligation to it: “If state is used for carrying application
state, and the integrity of its contents is a concern, clients MUST protect state
against tampering and swapping.”
PKCE genuinely is the stronger control where it applies. Section 4.7.1 says why:
“PKCE provides robust protection against CSRF attacks even in the presence of an
attacker that can read the authorization response (see Attacker (A3) in Section
3).” An attacker who reads a state value, by contrast, can replay it into a
forged response.
But there is a trap, and RFC 9700 gives it a section of its own. Section 4.8, the
PKCE downgrade attack, needs two preconditions, and the second one is the
decision you just made: “The second prerequisite for this attack is that the
client is not using state at all (e.g., because the client relies on PKCE for
CSRF prevention) or that the client is not checking state correctly.” An attacker
strips the code_challenge from an authorization request on their own device, the
server issues a code bound to no challenge, the code gets injected into the
victim’s flow, and the token endpoint never checks a verifier it was never given
a challenge for.
The RFC’s countermeasure section is unusually candid about why it has to place the burden on the server: “Using state properly would prevent this attack. However, practice has shown that many OAuth clients do not use or check state properly. Therefore, authorization servers MUST mitigate this attack.” Which is standards-body language for: we asked, you did not, so we are fixing it upstream.
The practical reading for someone shipping an app: use PKCE, and keep a bound
one-time state as well unless you have a reason not to. They cost the same
thing, they fail differently, and RFC 9700 spends a section explaining what
happens when you have only one of them.
What your managed backend already does with state
If you are on Supabase, GitHub or Google login does not run in your code at all, so it is worth knowing what the thing behind the button does. The answer is more interesting than the dashboard suggests, and it is readable in supabase/auth.
At the start of the flow, internal/api/external.go writes a row and uses the
row’s primary key as the state:
// Always create flow state for all flows (both PKCE and implicit)
// The flow state ID is used as the state parameter instead of JWT
flowState, err := models.NewFlowState(flowParams)
if err != nil {
return "", apierrors.NewBadRequestError(apierrors.ErrorCodeValidationFailed, "Invalid code_challenge_method").WithInternalError(err)
}
if err := db.Create(flowState); err != nil {
return "", apierrors.NewInternalServerError("Error creating flow state").WithInternalError(err)
}
// Use the flow state ID as the state parameter (UUID format)
authURL := p.AuthCodeURL(flowState.ID.String(), authUrlParams...)
The id is uuid.Must(uuid.NewV4()) in internal/models/flow_state.go, so the
state on the wire is a random version 4 UUID and nothing else. Everything that
used to travel inside the parameter now lives in the row, and the struct still
carries the receipt for that migration in a comment above its last five fields:
“OAuth context fields (previously stored in JWT state parameter)”. Those fields
are InviteToken, Referrer, OAuthClientStateID, LinkingTargetID and
EmailOptional.
That is Redash’s fix, arrived at independently: application state moved out of the parameter and onto a server-side record, leaving an opaque handle behind. It also means the redirect target is frozen at the moment the flow starts rather than read from the callback, which is a quietly good property.
The callback checks, in loadExternalState and loadExternalStateFromUUID, are
a decent list. Missing state is a 400, “OAuth state parameter missing”. A value
that is not a UUID is “OAuth state parameter is invalid”. A row that is absent or
past config.External.FlowStateExpiryDuration is “OAuth state not found or
expired”, and that duration has a floor rather than a default: configuration.go
defines defaultFlowStateExpiryDuration as 300 * time.Second and then raises
any configured value that is lower, so five minutes is the shortest window
available. Single use is enforced twice, once per flow type: the implicit branch
runs tx.Destroy(flowState) after issuing tokens, and the PKCE branch re-reads
the row ForUpdate inside the transaction and refuses it if UserID is already
set, with the comment “Re-fetch with FOR UPDATE lock inside the transaction to
prevent concurrent claims”.
Now hold that list against RFC 6749’s requirement, which is a value “that binds the request to the user-agent’s authenticated state”. Every check above is between the request and the database. None of them involves anything the browser holds. What they establish is that this flow was started through this project and has not been finished yet, which is a real and useful property and is not the same property as this browser started it.
The thing that supplies the missing half is PKCE, and on Supabase it is one
option away. getCodeChallengeAndMethod in auth-js writes the verifier into the
client’s own storage before the redirect:
const codeVerifier = generatePKCEVerifier()
let storedCodeVerifier = codeVerifier
if (isPasswordRecovery) {
storedCodeVerifier += '/PASSWORD_RECOVERY'
}
await setItemAsync(storage, `${storageKey}-code-verifier`, storedCodeVerifier)
const codeChallenge = await generatePKCEChallenge(codeVerifier)
and _exchangeCodeForSession reads it back out on the way home, sending
auth_code and code_verifier to /token?grant_type=pkce. A code produced by
somebody else’s flow cannot be redeemed by your browser, because your browser
does not hold the verifier that flow’s challenge was derived from. That is the
binding, and it lives in exactly the right place.
The default is not that. DEFAULT_OPTIONS in GoTrueClient.ts still sets
flowType: 'implicit', which is the same one-word change
the redirect URI post arrives at from the
other direction, for a different reason. Two independent arguments, one line:
export const supabase = createClient(url, anonKey, {
auth: {
// Under the 'implicit' default there is no code_verifier in this browser's
// storage, so nothing in the callback is bound to this browser.
flowType: "pkce",
},
});
PKCE turns itself off on a page served over http
This one is computed rather than quoted, so here is the derivation.
The Web Cryptography API defines the Crypto interface, and marks subtle
secure-context-only while leaving getRandomValues available everywhere:
[Exposed=(Window,Worker)]
interface Crypto {
[SecureContext] readonly attribute SubtleCrypto subtle;
ArrayBufferView getRandomValues(ArrayBufferView array);
[SecureContext] DOMString randomUUID();
};
So on a page served over plain http, crypto exists and produces random bytes,
and crypto.subtle does not exist. Note the carve-out, because it is the reason
nobody catches this in practice.
Secure Contexts returns “Potentially
Trustworthy” for an origin whose “host matches one of the CIDR notations
127.0.0.0/8 or ::1/128”, and, separately, for one whose host is localhost or
ends with .localhost. So loopback is a secure context and your development
machine gets s256 no matter what. The degrade appears only once the app is
served over http from somewhere else.
Now read auth-js’s challenge derivation against that:
export async function generatePKCEChallenge(verifier: string) {
const hasCryptoSupport =
typeof crypto !== 'undefined' &&
typeof crypto.subtle !== 'undefined' &&
typeof TextEncoder !== 'undefined'
if (!hasCryptoSupport) {
console.warn(
'WebCrypto API is not supported. Code challenge method will default to use plain instead of sha256.'
)
return verifier
}
const hashed = await sha256(verifier)
return btoa(hashed).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
The challenge becomes the verifier, and the caller then picks the method by
comparing them: const codeChallengeMethod = codeVerifier === codeChallenge ? 'plain' : 's256'. Running those two functions under Node with crypto.subtle
removed prints the warning and reports method plain, with the challenge equal
to the verifier; with crypto.subtle present the same code reports s256.
RFC 7636 section 7.2 says what
plain costs:
With the “plain” method, there is a chance that “code_challenge” will be observed by the attacker on the device or in the http request. Since the code challenge is the same as the code verifier in this case, the “plain” method does not protect against the eavesdropping of the initial request.
And it names the assumption plain runs on: it “relies on the operating system
and transport security not to disclose the request to an attacker.”
That is the circle. The condition that forces the downgrade, no transport security, is the exact condition under which the downgraded mode is unsafe. It is also OpenClaw’s CVE reached by configuration instead of by code: the verifier ends up in the URL either way.
This is why our free scan reports no-https-redirect as HIGH rather than as
a hygiene note. Its finding text is about the visitor: “A visitor who types your
domain without https, which is what most people do, stays on the unencrypted
version of the site. The secure version existing does not help anyone who never
reaches it.” The consequence reaches further than eavesdropping. A login flow on
that page is a login flow whose strongest protection has silently turned itself
off.
Writing the check yourself, if you own the callback
If you do own the callback, here is the shape. It is the same shape our own install flow uses, and every property in it is there to answer one question: did this browser start this flow?
The store is a table with a unique index on the hash:
create table oauth_flows (
state_hash text primary key,
session_id text not null,
expires_at timestamptz not null,
used_at timestamptz
);
-- The claim. One statement, so replays and races lose deterministically
-- instead of racing a read against a write.
update oauth_flows set used_at = now()
where state_hash = $1 and used_at is null and expires_at > now()
returning session_id;
And the two halves of the flow:
import { createHash, randomBytes } from "node:crypto";
const STATE_TTL_MS = 10 * 60 * 1000;
const sha256 = (value: string) => createHash("sha256").update(value).digest("hex");
// Start a flow: the raw token goes to the authorization server, only its hash
// is stored, and the row records which session asked for it.
export function startFlow(store: Store, sessionId: string, now: number): string {
const state = randomBytes(32).toString("base64url");
store.insert({
stateHash: sha256(state),
sessionId,
expiresAt: now + STATE_TTL_MS,
usedAt: null,
});
return state;
}
// Finish a flow. Returns true only if this browser started this flow, and
// consumes the row on the way through. Unknown, expired, replayed and
// mismatched all return the same false: telling them apart tells an attacker
// which of the four they hit.
export function consumeFlow(
store: Store,
state: string | undefined,
sessionId: string | undefined,
now: number,
): boolean {
if (typeof state !== "string" || !state) return false;
if (typeof sessionId !== "string" || !sessionId) return false;
const row = store.claim(sha256(state), now);
if (!row) return false;
return row.sessionId === sessionId;
}
Running that over a case matrix is more informative than reading it. The happy path passes. A replay of a consumed state fails. An attacker’s state presented in a victim’s browser fails, which is the whole point. An expired state, an absent state, an empty state, an invented state and a callback arriving with no session cookie all fail. And a retry after a mismatch also fails, because the row is consumed before the session is compared: one attempt per state, whoever makes it.
Four notes on the details, since each one is a decision rather than a style:
The sessionId is the pre-login session. A visitor clicking “sign in with
Google” has no account session yet, so the value being bound to is an anonymous
session cookie issued when the flow starts. Which means you have to be issuing
one, and you have to rotate it after the login succeeds, or you have traded login
CSRF for session fixation. MISP’s fix does
both halves in one commit and its comment explains the ordering: the plugin can
now call session_regenerate_id(true) after authentication, “Safe to do here
since the state check above no longer depends on the session ID staying fixed.”
Using the session id as the state is what had been blocking the rotation.
Only the hash is stored. The row is keyed by sha256(state), so a database
read does not yield usable state tokens, and the same reasoning applies here as
to a password reset ledger: the value in the table is not the value that opens
the door.
The comparison is a plain === and that is fine here. MISP’s fix used PHP’s
hash_equals, and that was correct for MISP, because their state was a session
identifier, a secret being compared. Here the secret is 256 bits of CSPRNG output
looked up by an index on its hash, and there is no partial match to walk toward.
Constant-time comparison matters when an attacker can iterate; it does not
manufacture entropy that is not there.
Failure has one message. Four different reasons, one answer. This is the same discipline that keeps a login form from becoming an account enumeration oracle.
If a cookie is genuinely the only place you can keep the value, the fastify
advisory’s own workaround is the one to copy: use the __Host- prefixed names
with secure: true and path: '/', which “switches the state and verifier
cookies to their __Host- prefixed names, which browsers refuse to accept from a
related host.”
What a scan can see, and what it cannot
The honest answer is narrower here than for most topics in this series, so it is worth being exact.
Nothing we run detects a missing or unbound state check. Not the free surface scan, not the runtime layer, not the SAST engines. The reason is not that nobody has written the rule. It is that the bug is an absence with no fixed shape: the comparison that should exist can live in a route handler, a middleware, a provider callback or a library configuration object, and the value it should be compared against can be a cookie, a session record or a database row. A rule that fired on “callback handler that does not compare state” would fire on every correct implementation that keeps the comparison one function away. This is a class where the five-layer scan reads your code, dependencies, secrets, configuration and running surface, and the finding still has to come from a human opening the callback file.
What the layers do reach:
- Dependencies. Every CVE in this post is a version range.
@fastify/oauth2below 8.3.0 and immich below 1.132.0 are exactly what OSV-Scanner and Grype exist to report. That is also the layer that will not save you here, because 8.3.0’s mitigation is off by default: the advisory is the work, not the bump. no-https-redirect, HIGH. For the reason worked through above, this finding is upstream of PKCE degrading toplain.no-referrer-policy, LOW. RFC 9700 section 4.2.3 is direct about the consequence: “If the attacker learns state, the CSRF protection achieved by using state is lost, resulting in CSRF attacks as described in Section 4.4.1.8 of [RFC6819].” Our finding text names the same mechanism from the other end: “The full URL of the page a visitor was on is sent to every site they click through to, which leaks anything you keep in a path or a query string, such as reset tokens and internal identifiers.” A callback URL is a page with credentials in its query string. Checkable with the security headers checker.- The cookie findings. CVE-2026-18165 is a cookie-integrity bug, and cookie attributes are the one part of this whole subject that is observable from outside. The cookie security checker reads what your responses actually set.
- Client configuration. The free scan reads the same-origin bundles,
which is where
createClientand its options end up. The tech stack checker and the Supabase key checker read the same material. A scan can see which authorization server you talk to. It cannot see what your callback compares.
The platform-specific starting points are in the
Lovable and
Bolt writeups, and the pattern there is the one this
post predicts: generated apps get social login from a client library, the client
library handles state on your behalf, and the setting that decides whether
anything is bound to your browser is a default nobody chose.
A token is not a binding
The reason this bug outlives every generation of OAuth advice is that the
parameter looks like plumbing. It is called state. Its one-line definition in
the spec is about round-tripping a value. Libraries fill it in for you. The code
that generates it looks obviously correct, because it is obviously correct, and
the code that fails to check it does not exist and therefore cannot be read.
So audit the other end. Open the callback handler and find the line where the returned value is compared, then ask what it is being compared against, and whether an attacker could have written that too. A constant fails. A cookie on a domain you share with a forgotten staging host fails. A row in your database proves the flow exists rather than that it is yours. A value only this browser could hold is the one that answers the question, and if that value is a credential you already had, you have moved the problem rather than solved it.
That is the whole test, and it is the same test MFA and token expiry turn out to be versions of. The credential is not the interesting part. What the credential is tied to is.