The app has no server to keep a client secret, so the login runs in the page. Somebody put the
tokens in localStorage so a reload would keep the session. The first script injected into the
origin reads them, posts them elsewhere, and keeps using the refresh token after the user has
closed the tab.
What you get
You will end up with a browser-only login that holds tokens in memory, survives a reload, and
rotates its refresh token, with a test that fails if anything lands in localStorage. This is
for you if you run a single-page app against an OpenID Connect issuer.
Short answer
Send the user to the issuer’s authorize endpoint with a fresh S256 code challenge, exchange the
returned code with the verifier, and keep the tokens in a variable. After a reload, renew
silently with prompt=none through a hidden iframe or with a rotating refresh token, and never
write a token to localStorage. A backend-for-frontend keeps tokens out of the browser entirely,
at the price of a server.
You will need
Node 22 or later, an issuer that supports PKCE, and a public client registered with it, with the
app’s callback as its redirect URI. Verified 2026-09-24 against Node 22.22.2 and a local stand-in
for the issuer. PKCE is RFC 7636, and
RFC 9700 makes it mandatory for every public client, so
an issuer that refuses code_challenge is one to replace rather than work around. The
architecture choices below are set out in RFC 10017,
the best current practice for browser-based apps.1
Approaches compared
| Approach | When it fits | What it costs you | When to pick something else |
|---|---|---|---|
| @auth0/auth0-spa-js | The issuer is Auth0 and you want memory storage and rotation with one option each | A client tied to one issuer, and an opt-in to localStorage its own page offers as the answer to cookie blocking | The issuer is anything other than Auth0 |
| @azure/msal-browser | The issuer is Microsoft Entra and you want its account and scope model | A cache in sessionStorage by default, so tokens are readable from the page, and an API shaped around Entra concepts | The issuer is not Entra, or the tokens must stay in memory |
| Backend-for-frontend | You can run a server on the app’s origin and want no token in the browser at all | A confidential client to host, a session cookie to protect, and every API call routed through the server | There is no server, or the app calls many APIs directly |
| oidc-client-ts | Any OpenID Connect issuer, with discovery, PKCE, and iframe renewal handled for you | A user store in sessionStorage unless you swap it for memory, and a silent renewal that depends on third-party cookies | You want one issuer’s account model, or no dependency |
The libraries differ most in where they put the tokens by default. auth0-spa-js keeps them in
memory unless told otherwise, msal-browser and oidc-client-ts keep them in sessionStorage,
and every one of the three can be pointed at localStorage by a single option. The
backend-for-frontend is the only row that takes the tokens out of the browser. It does so by
giving you a server to run, which is the thing a single-page app was avoiding.
Build the challenge with the browser’s own digest
The verifier is 32 random bytes as base64url, and the challenge is its SHA-256 digest in the
same encoding. Both come from
crypto.subtle, which
Node exposes on the same global as a browser does, so this file is the file the browser runs.
export function verifier() {
return base64url(crypto.getRandomValues(new Uint8Array(32)))
}
export async function challenge(verifier) {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
return base64url(new Uint8Array(digest))
}
Thirty-two bytes encode to 43 characters, the minimum RFC 7636 allows, and the padding is
stripped because the alphabet in the RFC has no =. The RFC ships a worked example in its
appendix, and the first test on this page checks the function against it.2
Keep the tokens in a closure and the verifier in sessionStorage
A login leaves the page, so the verifier has to wait somewhere for the redirect back. That
somewhere is sessionStorage, and it is the one thing this client writes there.
async login() {
const v = verifier()
const st = state()
browser.sessionStorage.set('pkce', JSON.stringify({ v, st }))
return browser.navigate(authorizeUrl({ codeChallenge: await challenge(v), st, silent: false }))
},
async handleRedirect(back) {
const { v, st } = JSON.parse(browser.sessionStorage.get('pkce') ?? '{}')
browser.sessionStorage.delete('pkce')
return redeem(back, v, st)
},
A verifier on its own is worthless. It unlocks one code, the code is single use, and the issuer
throws it away after a minute. The tokens that come back from redeem are held in a variable in
the module’s closure, returned to the caller, and exposed through one getter. browser here is a
small object standing in for window: navigate is location.assign on a login and a hidden
iframe on a renewal, and the two storage objects are the browser’s.
Renew silently after a reload
A reload empties the closure, so the app has to get the tokens back without a login screen.
The issuer still has its own session cookie. An authorize request with prompt=none, as
OpenID Connect Core defines it, asks
the issuer to answer from that cookie or fail with login_required, with no user interface
either way.
async silentRenew() {
const v = verifier()
const st = state()
const back = await browser.navigate(authorizeUrl({ codeChallenge: await challenge(v), st, silent: true }))
return redeem(back, v, st)
},
The request runs in a hidden iframe, so the page never unloads and the verifier can stay in the
closure. This is the flow oidc-client-ts and msal-browser both use, and it depends on the
browser sending the issuer’s cookie from inside an iframe on your origin, which is a
third-party cookie.
Browsers block those by default, in which case the iframe answers login_required and the
alternative is a refresh token.
Rotate the refresh token on the issuer
A refresh token in a browser is a bearer token, and RFC 10017 requires the issuer to either rotate it on every use or bind it to the sender. Rotation is what most issuers do, and the useful part is reuse detection.
if (!r.live) {
// A retired token came back. Someone has a copy, and there is no way to tell which
// of the two holders is the user, so every token in the family stops working.
family.revoked = true
return json(400, { error: 'invalid_grant', error_description: 'refresh token reused, family revoked' })
}
r.live = false
return json(200, issue(r.sub, r.family))
That is the issuer’s code, in the stand-in on this page. Yours does the same if it follows
Auth0’s description of rotation,
which is the behavior to look for in any issuer’s documentation. The client’s part is to send
the refresh token once, keep the replacement, and treat invalid_grant as a signal to log in again.
Check it worked
The demo starts the stand-in issuer, logs in, reloads, renews, rotates, and then reads storage the way an injected script would.
node demo.mjs
login: full redirect with PKCE S256
access token in memory: yes
refresh token in memory: yes
localStorage keys: none
sessionStorage keys: none
reload the page
access token in memory: no
silent renewal, prompt=none: ok, sub user_42
localStorage keys: none
refresh token rotation
refresh: new pair issued, previous refresh token retired
replay of the retired token: invalid_grant: refresh token reused, family revoked
the current token, after: invalid_grant: family revoked
an injected script reads storage
localStorage.getItem('access_token'): null
The reload block is the check the title promises. After the reload the closure is empty, the
renewal succeeds through the issuer’s cookie, and localStorage is still empty. The
sessionStorage line after the login matters too: the verifier was removed once the code was
exchanged, so nothing about the flow is left behind.
node --test pkce.test.mjs
1..5
# tests 5
# suites 0
# pass 5
# fail 0
# cancelled 0
# skipped 0
# todo 0
# duration_ms 275.104693
The first test is the example from the RFC. The second proves a code cannot be exchanged with the wrong verifier and cannot be exchanged twice, which is the property PKCE adds over a bare code.
When it goes wrong
The session survives a reload, and so does the theft. The tokens were written to
localStorage to
achieve the first, and any script running on the origin achieves the second.3
node pitfall.mjs
tokens written to localStorage so a reload keeps the session
injected script, localStorage.getItem('refresh_token'): refresh token read, 22 chars, sent to https://attacker.example/collect
tokens held in memory, session restored by silent renewal
injected script, localStorage.getItem('refresh_token'): null
Hold the tokens in a variable and restore the session through the issuer instead. Memory is not proof against the same script. RFC 10017 is plain that an injected script can call your own functions and read the closure. What memory removes is the persistence. A token that dies with the tab cannot be used from the attacker’s machine next week.
The silent renewal answers login_required for every user on one browser. That browser blocks
third-party cookies, so the issuer never sees its own session from inside your iframe. Ask the
issuer for refresh tokens with rotation and renew with those instead, which is the trade RFC
10017 describes as the reason refresh tokens became attractive for browser apps.
The exchange fails with invalid_grant after a login that looked fine. The verifier was written
in one tab and the redirect landed in another, or sessionStorage was cleared on the way back.
Send the user through the login again. A lost verifier is a lost login, never a security event.
When not to do this
Do not run the flow in the browser when you can run it on a server. RFC 10017 lists the three
architectures in decreasing order of security, and the browser-only client is last. A
backend-for-frontend holds the tokens in a server-side session behind an HttpOnly cookie,
and the app calls its own origin. That is the same shape as
calling a keyed API without shipping the key.
Do not use the implicit flow, whatever the issuer’s examples show. RFC 9700 forbids it, because the tokens arrive in the URL fragment and land in history and referrers.
Do not write a token to localStorage to fix a reload. Use one of the two reload fixes on this page
instead.
Do not build this client yourself if a library already covers your issuer. The stand-in here is
two hundred lines because it is also the issuer. oidc-client-ts handles discovery, the iframe,
the timers, and the expiry checks. The one setting to change is userStore, so that its user
object is held in memory rather than in sessionStorage.
Related how-tos
Last verified
Verified 2026-09-24 against Node 22.22.2. Every output block is what the command preceding it
printed. The issuer is the stand-in in issuer.mjs, on the loopback interface, and the browser
is the model in browser.mjs: a cookie jar, two storage objects, and one redirect. The three libraries
were not run; their storage defaults are read from the documentation each row links.
Footnotes
-
The document reached the RFC series as RFC 10017, Best Current Practice 212, in August 2026, with Aaron Parecki of Okta as first author. Its section 6 sets out three architectures and says they are presented in decreasing order of security. The browser-only client, which is the one every single-page app tutorial teaches and the one this page implements, is the third. ↩︎ Back to text
-
RFC 7636 closes with Appendix B, a worked example: the verifier
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXkand the challengeE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, published by Sakimura, Bradley, and Agarwal in September 2015. That pair is the first assertion inpkce.test.mjs, so the first test on this page was written by the IETF and the rest were written to match. ↩︎ Back to text -
Section 8.5 of RFC 10017 surveys the storage APIs and finds
localStoragereadable by any script on the origin, which is the point at hand. It then adds thatlocalStorageis also a synchronous API that blocks other JavaScript until the write completes. A security document pausing to note the performance cost of the thing it is warning you off is thoroughness of a high order. ↩︎ Back to text