Ask

Verification fails with "jwt malformed" — the token looks fine when I print it

The advice to decode without verifying is the single most useful debugging habit for this whole area.

Once you can see the decoded header and payload you can check, in seconds: the algorithm in the header matches what you expect, the issuer and audience are what you configured, and the expiry has not passed. Those three cover most verification failures that are not malformed strings.

Just be disciplined about where you decode. Pasting a production token into a website to inspect it means handing a live credential to a third party — it happens constantly and it is genuinely a security incident. Decode locally, in your own process, with a library call.

21 · in/sessions-vs-jwt ·

Single-page app cannot renew its token — the renewal call comes back saying there is no refresh token

Your memory is right and the change is not really the library's doing — browsers broke the old mechanism and the libraries followed.

What used to happen. Silent renewal worked through a hidden iframe: the app loaded the authorisation endpoint in an invisible frame, the browser sent the session cookie for the identity provider, the provider recognised the still-valid session and returned a fresh token without any user interaction. No refresh token anywhere.

That depends entirely on the identity provider's cookie being sent in a third-party context — your app is on one origin, the provider on another. Browsers now block third-party cookies by default, with varying degrees of finality. So the iframe loads, no cookie is sent, the provider sees an anonymous visitor, and silent renewal fails.

It does not fail cleanly, either — it typically fails as "login required", which reads like the session expired when the session is fine.

The replacement is refresh tokens, with rotation, which is why your library now expects one and reports its absence.

The configuration you need — all of these, and missing any one produces exactly your error:

  1. Enable refresh tokens in the client library. There is a flag for it. Off by default in some versions, which is the most common single cause.
  2. Request the offline access scope. This is the standard scope that asks for a refresh token. Without it the authorisation server issues an access token and nothing else, so there is genuinely no refresh token to use — which is what your error literally means.
  3. Allow the refresh token grant on the application registration. A server-side setting, separate from anything in your code. An application configured as a single-page app may have it disabled by default.
  4. Enable rotation. For a public client — which a single-page app is, because it cannot keep a secret — a non-rotating refresh token sitting in the browser is a long-lived credential. Rotation issues a new one on each use and invalidates the old, so a stolen token is usable once and detectably.

After changing scopes, existing sessions will not have a refresh token. They were issued without one and nothing retroactively adds it. So during the rollout you will still see the error for already-signed-in users until they sign in again. Handle that path gracefully rather than treating it as a bug — catch the failure, send them through login once, and it settles.

On where the refresh token is stored, since this is the trade you are accepting: in memory it is safest but lost on refresh of the page, which defeats the purpose. In browser storage it survives reloads and is readable by any script that runs on your origin, so a cross-site scripting flaw is a full account compromise. That is the honest cost.

The alternative worth considering if this matters: put a thin backend in front, keep the tokens server-side, and give the browser an ordinary secure, http-only, same-site session cookie. The front end then holds no tokens at all, the cookie is not readable by script, and none of this third-party cookie machinery applies. More infrastructure, considerably fewer sharp edges — and it is the direction most of this has moved.

30 · in/sessions-vs-jwt ·