CIAM: keep your tokens in memory

CIAM: keep your tokens in memory

Your CIAM platform can enforce MFA, run a polished login flow and issue short-lived tokens. Then your frontend serialises those tokens into browser storage because that is what the example did. We spend a lot of time discussing how people sign in. I think we should spend more time asking what we leave behind afterwards.

For a browser-only application using oidc-client-ts, I prefer keeping the authenticated user and tokens in memory. That reduces persistence and removes an easy place to collect credentials. It does not make the application immune to malicious JavaScript. That distinction matters just as much as the code.

CIAM does not end at login

Customer Identity and Access Management is the part of your product that deals with customer identities and access: registration, authentication, account recovery and related policies. OpenID Connect is commonly used to connect the application to an identity provider. A good provider cannot make every frontend storage decision for you.

There are several different tokens to think about. An ID token communicates authentication information to the client. An access token is presented to an API. A refresh token, when issued, can be used to request new tokens. They have different purposes and should not be treated as interchangeable strings.

An OIDC ID token is a JWT; access tokens are not necessarily JWTs. The storage issue applies either way. A bearer access token can grant access to whoever possesses it, within the token's validity and permissions. Its shape is not what makes careless storage dangerous.

Check what the package actually stores

This example targets oidc-client-ts 3.3.0, released in June 2025. In a browser, its defaults distinguish two stores:

  • userStore: defaults to sessionStorage. It persists the serialised authenticated user, including available ID, access and refresh tokens.
  • stateStore: defaults to localStorage. It retains protocol transaction state needed to complete an authentication request.

You can explicitly configure a different store. In particular, using new WebStorageStateStore({ store: window.localStorage }) as the user store makes that user data persist in local storage. That is a configuration choice, not the default user-store behaviour. The versioned UserManager settings and OIDC client settings show the difference.

“The JWT is in storage” is therefore too vague to fix properly. Look at the actual configuration and the actual user record. You may be keeping more than one token, plus profile information.

Session storage still survives a refresh

sessionStorage has a shorter scope than localStorage, but it is not an in-memory variable owned by your application. It survives reloads within a page session and is accessible to JavaScript running in the same origin and relevant tab context. Browser restoration behaviour also makes “closing the tab securely erases everything” a poor security guarantee. See the sessionStorage lifecycle.

Local storage lasts beyond the current page session and is shared across same-origin tabs. Neither mechanism provides an HttpOnly boundary. OWASP warns against storing sensitive authentication material in JavaScript-readable browser storage.

Why I want tokens in memory

Consider a portal that stores its user record after login. Later, a compromised script runs in that origin. With Web Storage, it has a standard API it can query for an existing record. The token does not need to be travelling through a request at that moment; the saved copy is waiting there.

An application-owned memory store removes that saved copy. A newly loaded document starts with an empty store instead of recovering previously issued tokens. Separate application instances do not automatically share credentials through local storage. I want those properties deliberately, rather than getting persistent credentials as an accidental convenience.

Auth0's storage guidance also recommends non-persistent memory storage for a SPA without a backend that can handle its tokens.

Reduce exposure, not just token lifetime

A short access-token lifetime is useful, but it does not justify keeping copies everywhere. A refresh token can extend the consequences of theft. Profile data can also be worth protecting even when an access token has expired.

I want fewer copies, a smaller lifetime for the application's copy and no automatic persistence across reloads. Do not undo that decision by putting the same user object in a persisted application store, an error report or a debugging log.

This is a practical preference for a SPA that must handle tokens itself. It is not a claim that an OAuth specification universally requires this particular memory class, or that a browser-only client is always the best architecture.

Memory is not an XSS cure

Malicious JavaScript executing in the application can still intercept token use, invoke exposed APIs or act through the user's session. Depending on the integration, it may obtain tokens through the same client code the application uses. A private field does not create a separate security boundary from the rest of the JavaScript runtime.

The March 2025 browser-based OAuth draft, a work in progress at the time, discusses these limits. The benefit is reduced persistence and less directly exposed storage, not a promise that an attacker in the page can no longer steal or misuse credentials.

Keep preventing XSS, restrict third-party scripts, maintain dependencies and deploy an appropriate Content Security Policy. Memory storage is one decision within that work. It cannot compensate for giving an untrusted script control of the application.

A storage class the package can use

The package exposes a StateStore interface with four asynchronous operations. Implementing it directly lets us pass our store to userStore without pretending to implement every part of the browser's Storage API.

Create memory-user-store.ts:

import type { StateStore } from 'oidc-client-ts';

export class MemoryUserStore implements StateStore {
  readonly #values = new Map<string, string>();

  async set(key: string, value: string): Promise<void> {
    this.#values.set(key, value);
  }

  async get(key: string): Promise<string | null> {
    return this.#values.get(key) ?? null;
  }

  async remove(key: string): Promise<string | null> {
    const value = this.#values.get(key) ?? null;
    this.#values.delete(key);
    return value;
  }

  async getAllKeys(): Promise<string[]> {
    return [...this.#values.keys()];
  }
}

remove returns the previous value, as required by the interface. Missing values return null. The nullish coalescing operator preserves an intentionally stored empty string. getAllKeys returns a new array, so modifying that array cannot change the underlying map.

The map belongs to the instance. A second instance starts empty. That is exactly the behaviour we want across new application documents, and exactly why creating a new store for every component would be a mistake.

Use one manager per application instance

Wire the class into a browser-only auth.ts module:

import { UserManager, WebStorageStateStore } from 'oidc-client-ts';
import { MemoryUserStore } from './memory-user-store.js';

// Browser-only module: one instance for this application document.
export const userManager = new UserManager({
  authority: 'https://identity.example.com',
  client_id: 'customer-portal',
  redirect_uri: `${window.location.origin}/auth/callback`,
  response_type: 'code',
  scope: 'openid profile api.read',

  userStore: new MemoryUserStore(),

  // Same-tab redirect state must survive leaving the application.
  stateStore: new WebStorageStateStore({
    store: window.sessionStorage,
    prefix: 'customer-portal.transaction.',
  }),

  // This minimal example uses explicit sign-in after expiry.
  // Configure and test renewal separately before enabling it.
  automaticSilentRenew: false,
});

The authority, client ID and scope are examples. Register the exact callback URL with your provider, use HTTPS in deployment and configure the API scope for your application. This is a public browser client: do not add a client secret to the JavaScript bundle. Authorization Code with PKCE remains enabled; the example does not disable PKCE.

In Angular, keep the manager in a root-provided authentication service or another single browser instance. Do not instantiate it per route or component. If the application uses server-side rendering, create this browser client only in the browser. Never use a process-wide server singleton to hold different visitors' tokens.

Do not lose the login transaction

It is tempting to put both stores in memory. For a normal full-page redirect, that breaks the flow: leaving the application destroys the original JavaScript context. When the provider redirects back, the new document no longer has the transaction needed to correlate and complete the response.

That transaction can include the PKCE verifier and other protocol data. It is sensitive too, but it has a different purpose and lifecycle from the authenticated user. In this example, it stays in sessionStorage long enough to support the same-tab redirect. Successful callback processing consumes the matching state; use the package's stale-state cleanup for abandoned transactions.

Do not place application tokens or private customer data in custom sign-in state. This design reduces token persistence; it does not claim that nothing sensitive ever reaches Web Storage.

Stay in the callback document

Process the callback using the same singleton manager that the application will continue using:

// In the browser callback route, before normal auth guards run:
await userManager.signinRedirectCallback();
window.history.replaceState(null, '', '/');
// Continue rendering the SPA with this same userManager instance.

Handle callback failures by showing a recoverable sign-in error, without logging tokens or the full callback URL. Configure your router to render the destination after processing. Do not follow this code with window.location.assign('/'): that starts another document and immediately discards the tokens you just put in memory.

The setup shown here is for a same-tab redirect. Popups, iframes and cross-tab coordination introduce different storage and communication requirements; test those flows separately instead of assuming this configuration covers them.

What happens on refresh?

A real page reload loses the application's stored user. getUser() then returns null. The identity provider may still have its own session, so a new authentication redirect may complete without asking for credentials again. That depends on the provider, session and policy; it is not guaranteed.

Plan that experience. Show an explicit sign-in path, preserve only safe navigation intent and avoid redirect loops when silent authentication fails. A server-backed session may be a better answer if uninterrupted reloads are a hard requirement.

Renewal and logout need decisions too

The example disables automatic silent renewal deliberately. It demonstrates storage and redirect handling, not a complete renewal setup. In production, decide how to handle expiry and whether to use supported refresh-token renewal or an iframe flow. Browser privacy controls can prevent iframe-based silent authentication from working.

If refresh tokens are issued to a public client, apply the appropriate rotation or sender-constraining protections and expiry policy. RFC 9700 describes the relevant refresh-token requirements. Storing a refresh token in memory does not make its misuse harmless while the application is running.

userManager.removeUser() removes the local user record. It does not automatically revoke every issued token or end the provider's session. Design sign-out, revocation where supported, and other-tab behaviour explicitly. Removing a map entry is also not a guarantee of secure erasure from physical memory or of removing references held elsewhere.

The built-in alternative

You do not have to maintain a custom class. Version 3.3.0 also exports InMemoryWebStorage. The equivalent user-store configuration is:

import {
  InMemoryWebStorage,
  WebStorageStateStore,
} from 'oidc-client-ts';

const userStore = new WebStorageStateStore({
  store: new InMemoryWebStorage(),
});
// Pass userStore to the same UserManager configuration above.

I included the custom implementation to make the contract and lifetime visible. For a straightforward project, the package's own implementation is a sensible choice. Neither becomes an XSS-proof vault because it has “memory” in its name.

Test the lifecycle

Before shipping the change, test the whole journey rather than only checking that a storage key disappeared:

  • Sign in through a real redirect and verify that the callback succeeds.
  • Confirm that the user record and tokens are absent from both Web Storage areas.
  • Navigate within the SPA and verify that the same manager still has its user.
  • Reload and verify that the empty-store experience is intentional and recoverable.
  • Exercise expiry, renewal failures, logout and multiple tabs.
  • Check that logging, persisted state and monitoring do not recreate a token copy.

The storage contract can also be unit tested: missing keys, overwritten values, removal, key enumeration and isolation between instances. Those tests protect the adapter. They do not replace an end-to-end login test against your CIAM provider.

When I would use a backend

For a sensitive customer portal, I would also consider a Backend for Frontend. The backend handles the OAuth tokens, while the browser receives a session cookie configured with HttpOnly, Secure and an appropriate SameSite policy. That keeps the OAuth tokens out of the browser's JavaScript environment altogether.

The BFF still needs CSRF protection and correct authorisation. XSS can still initiate actions through the browser even when it cannot read the cookie. It changes the exposure; it does not remove the need to secure the application.

For a browser-only integration, memory is my starting point because I do not want persistent credentials to be the price of a convenient refresh. If that trade-off does not fit the product, I would revisit the architecture. I would not silently move the tokens into local storage and call the security discussion finished.

Comments

There are no comments yet, leave yours below.

Leave a comment

Do you have an addition, question or experience related to this article? Share it below.

Comments are briefly reviewed before they appear.

Read more about:

Security by design in Angular

Security by design in Angular

A route guard and a green pipeline do not stop one customer reading another customer's invoice. Using Angular and a .NET API, I turn a security requirement into server-side ownership checks and tests. Security by design starts with boundaries and evidence, before choosing a scanner.

Continue reading

Starting fresh with Angular

A new Angular project is an opportunity to make fewer premature decisions. I work through a practical starting point: one useful vertical slice, clear feature boundaries, deliberate state management and tests that protect real behaviour. The goal is a codebase that can grow with the product, without building an imaginary enterprise platform on day one.

Continue reading

The bullshit of slow SAST scans

The bullshit of slow SAST scans

A security scan that keeps developers waiting can undermine the workflow it is supposed to protect. I look at feedback time, useful pipeline gates and the cost of noisy results. Faster checks should help teams act on real findings, without quietly removing the security coverage they need.

Continue reading