How-to › Authenticate and authorize calls

How to run authorization code with PKCE in a single-page app#

Log a user into a browser-only app with PKCE, keep the tokens in memory, renew them silently after a reload, and prove nothing is ever written to local storage.

Audience
API consumer
Level
intermediate
Topic
Run OAuth 2.1 and OIDC flows
Languages
TypeScript
Verified

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

ApproachWhen it fitsWhat it costs youWhen to pick something else
@auth0/auth0-spa-jsThe issuer is Auth0 and you want memory storage and rotation with one option eachA client tied to one issuer, and an opt-in to localStorage its own page offers as the answer to cookie blockingThe issuer is anything other than Auth0
@azure/msal-browserThe issuer is Microsoft Entra and you want its account and scope modelA cache in sessionStorage by default, so tokens are readable from the page, and an API shaped around Entra conceptsThe issuer is not Entra, or the tokens must stay in memory
Backend-for-frontendYou can run a server on the app’s origin and want no token in the browser at allA confidential client to host, a session cookie to protect, and every API call routed through the serverThere is no server, or the app calls many APIs directly
oidc-client-tsAny OpenID Connect issuer, with discovery, PKCE, and iframe renewal handled for youA user store in sessionStorage unless you swap it for memory, and a silent renewal that depends on third-party cookiesYou 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.

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

  1. 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

  2. RFC 7636 closes with Appendix B, a worked example: the verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk and the challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM, published by Sakimura, Bradley, and Agarwal in September 2015. That pair is the first assertion in pkce.test.mjs, so the first test on this page was written by the IETF and the rest were written to match. ↩︎ Back to text

  3. Section 8.5 of RFC 10017 surveys the storage APIs and finds localStorage readable by any script on the origin, which is the point at hand. It then adds that localStorage is 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

Read this page as markdown · All how-to guides

Generate the client instead of writing it#

Retries, timeouts, pagination and auth are the same problems in every client. Voxgig generates them from your OpenAPI description, in 23 languages, from one model.

Get the Voxgig dispatch

Short notes on building SDKs, CLIs, REPLs, and MCPs for API-first teams, plus the occasional Fireside episode pick.

By signing up you agree to our Terms and Conditions.