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

Source: https://voxgig.com/howto/pkce-in-a-single-page-app

- Audience: api-consumer
- Level: intermediate
- Languages: typescript
- Verified: 2026-09-24
- Published: 2026-09-24

## 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](https://www.rfc-editor.org/rfc/rfc7636), and
[RFC 9700](https://www.rfc-editor.org/rfc/rfc9700) 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](https://www.rfc-editor.org/rfc/rfc10017),
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](https://auth0.com/docs/libraries/auth0-single-page-app-sdk) | 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](https://learn.microsoft.com/en-us/entra/msal/javascript/browser/caching) | 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](https://www.rfc-editor.org/rfc/rfc10017) | 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](https://authts.github.io/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`](https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest), which
Node exposes on the same global as a browser does, so this file is the file the browser runs.

```ts title="pkce.mjs"
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.

```ts title="auth.mjs"
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](https://openid.net/specs/openid-connect-core-1_0.html) defines it, asks
the issuer to answer from that cookie or fail with `login_required`, with no user interface
either way.

```ts title="auth.mjs"
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](https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Third-party_cookies).
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.

```ts title="issuer.mjs"
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](https://auth0.com/docs/secure/tokens/refresh-tokens/refresh-token-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.

```bash
node demo.mjs
```

```text output
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.

```bash
node --test pkce.test.mjs
```

```text output
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`](https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage) to
achieve the first, and any script running on the origin achieves the second.[^3]

```bash
node pitfall.mjs
```

```text output
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](/howto/keep-an-api-key-out-of-browser-code).

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

- [Add a bearer token to fetch without a client library](/howto/bearer-token-fetch-wrapper)

- [Refresh an access token once under concurrent requests](/howto/refresh-a-token-once-under-concurrent-requests)

- [Diagnose a 401 or a 403 from a credential](/howto/diagnose-401-and-403-from-a-credential)

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

[^1]: The document reached the RFC series as
[RFC 10017](https://www.rfc-editor.org/rfc/rfc10017), 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.

[^2]: [RFC 7636](https://www.rfc-editor.org/rfc/rfc7636) 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.

[^3]: Section 8.5 of [RFC 10017](https://www.rfc-editor.org/rfc/rfc10017) 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.