npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@antzsoft/auth-web

v1.0.5

Published

Server-agnostic token lifecycle management for browser apps — proactive refresh, focus revalidation, cross-tab sync, offline retry. Bring your own auth API.

Readme

@antzsoft/auth-web

Token-lifecycle management for browser apps. You bring the auth API; the SDK keeps the session alive across tab focus, network drops, and other tabs.

It knows nothing about any particular auth server. Point it at your own endpoints by writing a small transport, and it handles the parts that are the same everywhere and easy to get subtly wrong.

Need SSO as well? It is built in — see Both auth modes, one package below. The standalone WSO2 SDKs remain published for apps already using them; this package does not depend on either.

What's new in 1.0.5

Storage namespacing has exactly one home now: config.storageKeyPrefix.

This is a breaking change — deliberately, because the shape it replaces could silently break a session. storageKeyPrefix used to be accepted on the sso config as well as on config, and the two configured different halves of the same session:

| | reads the prefix from | |---|---| | the SSO client (does PKCE, stores the tokens) | sso.storageKeyPrefix | | the engine (decides whether a session exists) | config.storageKeyPrefix |

Set it on sso alone and the client wrote tokens under spm_antz_auth_* while the engine looked for them under sso_antz_auth.*. Sign-in succeeded, the tokens were on disk, and the app showed the login screen anyway — with nothing logged to explain it. Worse, the engine's fallback namespaces (sso_, direct_) are not app-specific, so two apps sharing an origin also collided with each other.

What to change

  <AuthProvider
    transport={transport}
    sso={{
      baseUrl, clientId, redirectUri,
-     storageKeyPrefix: 'myapp',        // ← no longer a field; compile error
    }}
    config={{
+     storageKeyPrefix: 'myapp',        // ← here, and only here
    }}
  >

The provider appends the mode itself and derives both namespaces from that one value — myapp_sso_… for the SSO half, myapp_direct_… for the direct half — passing the client's to it as a constructor argument. There is no second place to put it, so the two cannot disagree.

If you were already setting it on config, nothing changes: you were correct, and your keys are unchanged.

AntzAuthClient is no longer exported. Only the provider can construct a client whose namespace matches the engine's, so only the provider constructs one. Reach the live instance the way the samples already do:

const ssoClient = useSsoClient();                    // React
const { ssoClient } = createAuth({ ... });           // Vue

Two Vue-only bugs fixed. createAuth() was passing neither the storage prefix nor the expiry flag through to the SSO half:

  • The client got no prefix at all, so Vue produced the split above even for an app that had configured config.storageKeyPrefix correctly. React forwarded it; the two providers had drifted apart.
  • createSsoTransport() was called without its second argument, so wantRefreshExpiry stayed false and onExpiryWarning never fired in SSO mode, however enableExpiryCheck was set.

Both providers now share one joinPrefix() so they cannot drift again, and the invariant — client namespace must equal engine namespace, in both frameworks — is covered by tests.

Also worth knowing

sso.refreshBufferSeconds and sso.sessionPollIntervalSeconds still exist, and still configure the client only. The engine reads its own copies from config. The options table previously described them as "inherited", which was never true. Set them on config unless you specifically want the client to differ.

What's new in 1.0.4

useSharedSessionChanged() — the hook-level shared-session callback is back.

The standalone WSO2 SDK let a React or Vue component subscribe to a change in the browser's shared provider session through an onSharedSessionChanged hook option. That wiring lived in its framework adapters, which this package does not use — so the option had no equivalent here, and a comment in our own source still told you to reach for it.

import { useSharedSessionChanged } from '@antzsoft/auth-web/react';

useSharedSessionChanged(({ reason, previousUser }) => {
  if (reason === 'different_user') queryClient.clear();
});

Vue takes the client createAuth() returned, since there is no provider to read it from:

useSharedSessionChanged(ssoClient, ({ reason }) => { ... });

The event itself was never missing — sso.onBeforeSessionSwitch has always carried it. What is new is per-component subscription: config gives you one app-wide handler, this gives a component its own, tied to its lifetime. Both fire if both are set, config first, then each listener in registration order. A listener that throws is logged and skipped rather than blocking the switch.

Two SSO options that already worked are now documented. Neither is new; both were undocumented, so a migrating app could reasonably conclude they had been dropped:

| Option | What it does | |---|---| | sso.authorizeAppAccess | Your own authorization rule, run after WSO2 issues a token but before it is stored. Return false to reject a user WSO2 is happy to authenticate — the SDK raises AntzAppAccessDeniedError | | login({ autoSwitchAccount: true }) | With a loginHint, recover from a different-user session automatically (re-issues prompt=login) instead of throwing AntzSessionUserMismatchError |

See authorizeAppAccess and autoSwitchAccount below.

Nothing else changed, and nothing is deprecated. Upgrading from 1.0.3 is a version bump.

What's new in 1.0.3

Expiry warnings, reworked — one API for both auth modes.

The refresh token's expiry is when a session ends for good. Warning about it used to mean two overlapping options, one of which only worked in SSO. Now there is one:

config={{
  enableExpiryCheck: true,
  expiryWarningThresholds: [86_400, 3_600, 300],   // a day, an hour, 5 min
  onExpiryWarning: ({ threshold, secondsRemaining }) => { ... },
}}

Pass several thresholds to warn more than once as expiry approaches; the callback is told which one fired, so a single handler can escalate from a quiet banner to a blocking prompt. Each fires once per session, and one already passed when the app opens fires on startup — so a browser closed through the moment still gets its warning.

This works in SSO now. The SSO transport never carried refreshExpiresAt through to the engine, so the check was silently inert in that mode. It is fetched from the provider when — and only when — enableExpiryCheck is on, since it costs one extra call per refresh.

Dead callbacks removed from the SSO config. onSessionExpired — and on mobile onTokenRefreshed — were declared on the sso prop as well as config. The sso copies never fired: the code that called them belonged to the standalone WSO2 SDK's own React and Vue bindings, which this package replaces. An app that set them there got silence.

They now exist only on config, where they fire in both auth modes, so passing one to sso is a type error rather than a quiet no-op. The unreachable bindings themselves are gone too — 68 KB of code that shipped in every install and could not be reached from any import path.

onBeforeSessionSwitch stays on sso, and does work: it reports a change to the browser's shared provider session, which has no non-SSO counterpart. It was simply undocumented — see onBeforeSessionSwitch — SSO only below.

verifyCredentials() — re-check a password without starting a session. For a re-authentication gate ahead of a sensitive change. Previously RN-only and SSO-only; now on useAuth() in both packages and available in either auth mode:

const { valid, reason } = await verifyCredentials({ password });

Non-SSO reaches it through a new verifyCredentials endpoint on the transport — point it at a dedicated verify route, not your login one. On web, capabilities.verifyCredentials is false in SSO mode (the provider owns the login page), so gate the UI on the capability rather than the mode. See Re-checking a password below.

Reading token expiry — no getSessionInfo() needed. The WSO2 SDK exposed getSessionInfo() on useAuth() to fetch the server's expiry timestamps. This package does not, because both values are already on the token set and work the same way in either mode:

const { tokens } = useAuth();
tokens.expiresAt         // access token  — always present
tokens.refreshExpiresAt  // refresh token — SSO needs enableExpiryCheck: true

Non-SSO gets refreshExpiresAt free with the token response. SSO must ask the provider for it, so that lookup is gated behind enableExpiryCheck and apps that never read the value pay nothing. See Reading token expiry below.

Removed: enableDailyExpiryCheck, onDailyExpiryWarning, dailyCheckHour, dailyCheckMinute and expiryWarningWindowSeconds are gone from config. They are gone from the sso prop too — an earlier build kept them there for parity with the copied WSO2 client, but nothing read them, so passing one is now a type error in either place rather than a silent no-op.

One SSO client method went with them: checkRefreshTokenExpiringSoon(). It existed to drive the daily scheduler and has no role now that thresholds are computed from the token's own expiry. If you called it directly, use getRefreshTokenExpiresAt() — or better, read tokens.refreshExpiresAt off the token set and let onExpiryWarning fire.

Migrating:

- enableDailyExpiryCheck: true,
- dailyCheckHour: 5,
- expiryWarningWindowSeconds: 86_400,
- onDailyExpiryWarning: () => banner('Session ends today'),
+ enableExpiryCheck: true,
+ expiryWarningThresholds: [86_400],
+ onExpiryWarning: () => banner('Session ends today'),

The old daily check ran at a fixed hour; thresholds fire relative to the token's own expiry, so there is no clock to configure.

What's new in 1.0.1

Every fix here is in the SSO half. Non-SSO behaviour is unchanged, and no application code needs to change to pick these up.

SSO sign-in now completes. After the authorization-code exchange the session was established but never adopted, so the app rendered its login screen despite a successful sign-in — and only picked it up on a manual reload. Three separate faults stacked up:

  • The session was read from the wrong place. An SSO session lives in the SSO client, not in the engine's storage, so restore() looked somewhere that was always empty. It now asks the client.
  • The exchange result was discarded in dev. React Strict Mode double-invokes effects: the first mount's cleanup cancelled the in-flight exchange while a guard blocked the second mount from starting a new one, so nothing ever finished. The exchange is now a shared promise a remount joins.
  • The wrong mode's session was restored. On a server-rendered route the mode store reports its fallback during hydration, so the callback restored the non-SSO session. It now targets the SSO one explicitly.

Server-rendered apps no longer hydrate mismatched. The active mode comes from localStorage, which the server cannot read, so any UI branching on it rendered differently on each side — React reported "Text content does not match server-rendered HTML". New useIsModeReady() returns false for the first client render, so mode-dependent UI can hold neutral markup until the real mode is known. Next.js only; React and Vue never had it.

Switching modes keeps the mode. An SSO logout navigates the browser away, so the write that recorded the new mode never ran and the app came back in the mode it had just left. The mode is now persisted before the logout.

Fewer redundant renders and requests. Re-asserting unchanged state used to publish a new object and re-render every consumer — a visible storm on every tab refocus. State updates are now skipped when nothing changed, a re-restore over a live session no longer flashes loading, and the cross-app SSO check is throttled instead of firing a provider round trip per refocus.

Also: postLogoutRedirectUri is documented — it must match a registered redirect URL exactly, and a trailing slash is enough for the provider to reject the logout.

What it does for you

| When | What happens | |---|---| | Access token nears expiry | Refreshes proactively, before any request is rejected | | Two refreshes race | A single-flight lock shares one result — no double-spent rotating token | | Tab regains focus | Revalidates if the token went stale while the tab was throttled (throttled itself, default 5 min) | | Network comes back | Retries immediately on online, but only if a refresh was actually failing | | Another tab logs out | This tab follows, via the storage event | | Device is offline | Keeps the session — a network error or 5xx is transient, not a dead credential | | Refresh token is genuinely dead | Ends the session and calls onSessionExpired |

That last pair is the one worth reading twice. Treating a transient failure as a dead credential logs people out whenever their wifi hiccups; treating a dead credential as transient leaves the app retrying forever. The rule lives in one place — isRetryable — rather than being re-derived at each call site.

Install

npm install @antzsoft/auth-web

React 18+ is an optional peer dependency, needed only for the hooks. The SessionManager underneath is framework-free.

Framework bindings

import { SessionManager, createRestTransport } from '@antzsoft/auth-web';       // engine
import { AuthProvider, useAuth }               from '@antzsoft/auth-web/react'; // React

The React bindings are deliberately not on the root barrel. A bundler resolving the root import would otherwise pull React in even for a consumer that never uses it — and fail on the missing optional peer dependency. A Vue or vanilla app imports SessionManager from the root and wires its own listeners; sample-vue-dual-auth does exactly that, in about forty lines.

Both auth modes, one package

This SDK ships non-SSO and SSO together. There is no second package to install and no dependency on one: the SSO implementation lives inside this package, carried over verbatim so its behaviour is unchanged.

<AuthProvider
  transport={transport}        // non-SSO — your own API
  sso={ssoConfig}              // SSO — an OAuth2/OIDC provider
  defaultMode="direct"         // where a first visit starts
  config={{ storageKeyPrefix: 'myapp' }}
>

Supply either, or both. Supplying both gives you a runtime switch:

const { mode, switchMode, canSwitchTo, isSwitching, capabilities } = useAuth();

await switchMode('sso');   // logs out of the current mode first

That logout is not optional. Leaving a live session behind means the outgoing mode keeps refreshing tokens for a user who has apparently signed out, and switching back silently restores a session they thought they had ended. In SSO's case it also leaves the shared provider session alive for every other app in the browser.

Each mode gets its own storage namespace beneath your prefix, so neither can read the other's tokens.

One engine, two modes

The two modes differ only in how tokens are obtained. Once they exist, the same SessionManager drives both — the proactive timer, single-flight refresh lock, retry policy, storage and cross-tab sync are shared, with no branching inside the engine.

That is achieved by wrapping the SSO client so it satisfies the same AuthTransport interface a non-SSO transport implements.

What differs, and how to handle it

login() is the one genuine asymmetry:

| | Non-SSO | SSO (web) | SSO (mobile) | |---|---|---|---| | login() | takes credentials, resolves | takes nothing, navigates away | takes credentials, resolves | | Callback route | no | yes | no | | Focus / foreground recheck | refreshes an expiring token | same, plus a cross-app silent check when signed out | same as non-SSO — no shared session |

Screens should ask capabilities, not the mode:

capabilities.credentialLogin
  ? <form>…</form>                      // credentials collected here
  : <button>Continue with SSO</button>  // provider collects them

Every flag, and what each mode reports:

| Capability | SSO | Non-SSO | |---|---|---| | credentialLogin | false — the provider's own page collects them | true when the transport has login | | changePassword | true | true when the transport has changePassword | | verifyCredentials | false — the SDK never sees the password | true when the transport has verifyCredentials | | passwordRecovery | true — the provider hosts the page | false — your own API owns it | | usesRedirectCallback | true — needs a /callback route | false |

On web the provider owns the login page, so SSO cannot collect or verify a password itself. The React Native package differs: its App-Native flow takes credentials directly, so both credentialLogin and verifyCredentials are true there.

Usage

1. Describe your API

// auth/transport.ts
import { createRestTransport, expiryFromJwt, type AuthResult } from '@antzsoft/auth-web';

interface ServerTokens {
  token: string;
  refreshToken: string;
  user?: { id: string; email: string; name: string };
}

// The ONLY place your server's field names appear.
function mapTokens(res: ServerTokens): AuthResult {
  const expiresAt = expiryFromJwt(res.token);
  if (expiresAt === null) throw new Error('Token has no readable expiry');

  return {
    tokens: {
      accessToken:      res.token,          // your name → the SDK's
      refreshToken:     res.refreshToken,
      expiresAt,                            // epoch MILLISECONDS
      refreshExpiresAt: expiryFromJwt(res.refreshToken) ?? undefined,
    },
    user: res.user
      ? { sub: res.user.id, email: res.user.email, givenName: res.user.name }
      : null,
  };
}

export const transport = createRestTransport({
  baseUrl: 'https://api.example.com',

  login:   { path: '/api/auth/login', map: (r) => mapTokens(r as ServerTokens) },
  refresh: {
    path: '/api/auth/token',
    body: (refreshToken) => ({ refreshToken }),
    // user: undefined → the SDK keeps the user it already knows, instead of
    // blanking your header if refresh returns a thinner object than login did.
    map: (r) => mapTokens({ ...(r as ServerTokens), user: undefined }),
  },
  logout:          { path: '/api/auth/logout' },
  validateSession: { path: '/api/auth/me', method: 'GET' },   // optional
  changePassword:  { path: '/api/auth/change-password' },     // optional
});

The one hard rule: expiresAt is epoch milliseconds. A JWT's exp is in seconds — use expiryFromJwt() rather than multiplying by hand.

Every endpoint takes an optional method ('GET' | 'POST' | 'PUT'); defaults are GET for validateSession and POST for the rest.

When success isn't the HTTP status

Some APIs answer HTTP 200 with a failure flag in the bodysuccess: false, status: "error", a non-zero errorCode. Read the status alone and a failed login looks like a successful one that stored nothing usable.

login: {
  path: '/api/v2/auth/login',
  isSuccess: (body) => (body as any).success === true,
  errorStatus: 401,
  map: mapTokens,
},

errorStatus is the part that's easy to miss. isRetryable() decides keep-vs-end-session from the status, and a bare 200 classifies as transient — so without it, bad credentials would be retried forever instead of ending the session. Default 401.

It can also vary by body:

errorStatus: (body) => ((body as any).rateLimited ? 429 : 401),

Opaque (non-JWT) refresh tokens

Nothing in the engine decodes a token, so an opaque refresh token needs no special handling — read the expiry from whatever field the server sends:

map: (res: any) => ({
  tokens: {
    accessToken:      res.token,
    refreshToken:     res.refresh_token,        // opaque — never decoded
    expiresAt:        res.token_expiry * 1000,        // epoch SECONDS → ms
    refreshExpiresAt: res.refresh_token_expiry * 1000,
    meta: { session_id: res.session_id },       // carried, never read
  },
  user: { sub: String(res.user.user_id) },
}),

expiryFromJwt() is a convenience for JWT access tokens, not a requirement. Both token styles are first-class.

2. Mount the provider

import { AuthProvider } from '@antzsoft/auth-web/react';
import { transport } from './auth/transport';

<AuthProvider
  transport={transport}
  config={{
    // Namespaces keys so apps sharing this ORIGIN don't collide.
    storageKeyPrefix: 'analytics',
    refreshBufferSeconds: 60,       // refresh this long before expiry
    revalidateOnFocus: true,        // recheck when the tab is shown again

    // ── React to session events ────────────────────────────────────────
    onSessionExpired:  () => navigateToLogin(),
    onTokenRefreshed:  (tokens) => socket.reauth(tokens.accessToken),
    onRefreshFailed:   ({ attempt }) => { if (attempt >= 2) showBanner('Reconnecting…'); },
    onExpiryWarning:   () => toast('Your session ends soon'),
  }}
>
  <App />
</AuthProvider>

3. Use it

const {
  status, user, tokens, error,
  login, logout, getAccessToken, refresh,
  changePassword, clearError, isAccessTokenExpired, setTokens,
} = useAuth();

Gate on status. idle and loading are distinct from unauthenticated — treating them as logged-out flashes the login screen on every launch:

if (status === 'idle' || status === 'loading') return <Splash />;
if (status === 'unauthenticated') return <LoginScreen />;
return <Dashboard />;

Log in. The result arrives via state, not the return value:

setBusy(true);
try {
  await login({ username, password });   // credentials pass through untouched
} catch (err) {
  showError((err as Error).message);     // also available as `error`
} finally {
  setBusy(false);
}

Call your API. Always getAccessToken(), never tokens.accessToken — the latter is a render snapshot that may already be seconds from rejection:

const token = await getAccessToken();     // refreshes first if near expiry
await fetch('/api/orders', { headers: { Authorization: `Bearer ${token}` } });

Change a password. Ends the session by default — see below:

await changePassword({ currentPassword, newPassword });

Log out. Clears local state even if the server call fails:

await logout();

Adopt tokens minted elsewhere (SSR bootstrap, a native bridge, a deep link):

await setTokens({ accessToken, refreshToken, expiresAt }, user);

What you call vs. what the SDK calls

Only three of your calls hit the network directly. The rest is automatic:

| Call | Triggered by | |---|---| | login · logout · changePassword | You, from the UI | | getAccessToken | You — may trigger a refresh underneath | | refresh | The SDK: proactive timer, tab focus, online event, restore-on-mount, after a failed validateSession | | validateSession | The SDK: the revocation poll |

That asymmetry is why the callbacks matter — most refreshes have no await of yours to wrap.

What happens on focus, and across tabs

Both of these work identically in both auth modes — they belong to the engine, not to SSO. Neither needs a page reload; state updates in place.

On tab focus (revalidateOnFocus, default true). When the tab becomes visible the SDK calls the same check its own timer uses:

| Situation | What happens | Cost | |---|---|---| | Access token still outside the refresh buffer | nothing | none — an in-memory expiry comparison | | Access token inside the buffer, or expired | one refresh | one request | | No local tokens, non-SSO | nothing — signed out is signed out | none | | No local tokens, SSO | re-runs the cross-app silent check | one prompt=none round trip |

That last row is the only SSO-specific part. It exists because the provider session is shared across every app on the origin, so the user may have signed in through a sibling app since this tab was last looked at; picking that up on refocus beats making them reload. In non-SSO there is no external place a session could appear from, so there is nothing to check for.

Throttled by revalidateOnFocusThrottleSeconds (default 300), because visibilitychange fires on every alt-tab. The SSO recheck carries a second, independent floor of its own — on a signed-out tab the answer is almost always "still signed out", and without it every refocus would be a wasted request.

Across tabs (syncAcrossTabs, default true). A logout or a refresh in another tab is followed here immediately — on the storage event, not on next focus. This matters more than it looks with rotating refresh tokens: without it the other tabs keep an already-rotated token, its next use fails, and isRetryable() reads that as a dead credential and signs the user out.

React Native has the same behaviour under a different name: refreshOnForeground (default true) runs the identical check when the app becomes active. It needs no throttle — AppState fires once per foreground, not on every window switch — and it has no cross-app branch, since App-Native Authentication shares no session between apps.

Reacting to what the SDK does on its own

| Callback | Fires when | Typical use | |---|---|---| | onTokenRefreshed(tokens) | Every successful refresh | Push the new token to a socket client or a plain fetch module that can't observe React state | | onRefreshFailed({ error, attempt }) | A refresh failed but the session is kept (network, timeout, 5xx) | Show "reconnecting…". attempt counts consecutive failures, so you can stay quiet on the first | | onSessionExpired() | The refresh token is dead, or logout() ran | Route to login. Not called on a first visit with no stored session | | onExpiryWarning({ threshold, secondsRemaining, expiresAt, tokens }) | Each expiry threshold is crossed | Warn before a hard session end. threshold says which one fired, so one handler can escalate. Needs enableExpiryCheck |

Without onRefreshFailed a transient failure is invisible: status stays authenticated and nothing else fires, so a stalled session looks identical to a healthy one.

Writing a transport by hand

createRestTransport covers JSON-over-HTTP. Anything else — a signed header, a two-step login, a non-JSON body — is a sign to write the object directly. It's four methods, and dropping down to it is expected rather than exceptional:

import { AuthTransportError, type AuthTransport } from '@antzsoft/auth-web';

export const transport: AuthTransport = {
  // The only required method.
  async refresh(refreshToken) {
    const res = await fetch('/api/auth/token', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken }),
    });
    // Throw AuthTransportError so the status survives — that status is what
    // decides retry vs. logout.
    if (!res.ok) throw new AuthTransportError(res.status, await res.text());
    const d = await res.json();
    return {
      tokens: { accessToken: d.token, refreshToken: d.refreshToken, expiresAt: d.expiresAt },
      user: null,
    };
  },

  async login(credentials) { /* ... */ },
  async logout(tokens)     { /* ... */ },
  // validateSession is optional — omit it and the revocation poll never starts.
};

One hard requirement: expiresAt must be epoch milliseconds. A JWT's exp claim is in seconds — use expiryFromJwt() rather than multiplying by hand. Getting this wrong makes tokens appear to expire in 1970 and triggers an immediate refresh loop.

Hooks around every request

createRestTransport takes three optional observers, so cross-cutting concerns live in one place instead of being copied into each endpoint.

createRestTransport({
  baseUrl,
  login:   { path: '/api/auth/login',  map: mapTokens },
  refresh: { path: '/api/auth/token',  map: mapTokens },

  // Before every request. Return a partial RequestInit to merge in.
  onRequest: async ({ operation, url, method, body }) => {
    showSpinner(operation === 'login');
    return { headers: { 'X-Trace-Id': crypto.randomUUID() } };
  },

  // After every response — success or failure.
  onResponse: ({ operation, status, ok, durationMs }) => {
    metrics.timing(`auth.${operation}`, durationMs, { status });
  },

  // On any failure: non-2xx, network error, or timeout.
  onError: ({ operation, status, error }) => {
    reportToSentry(error, { operation, status });
  },
});

operation is one of login | refresh | logout | validateSession | changePassword, so one hook can branch per call.

Three behaviours worth knowing:

  • Headers from onRequest are merged last, so they can override Content-Type or replace Authorization for a scheme this transport doesn't know.
  • onRequest runs before the timeout is armed. Real work there — minting a signature, awaiting device attestation — doesn't eat the request's budget. Throwing aborts the request; wrap it in AuthTransportError if the retry-vs-logout classification matters.
  • onResponse and onError cannot change the outcome. Anything they throw is swallowed, so a typo in a logging callback can't replace your real 401.

For logic that isn't per-request — anything around the session rather than a single call — use the provider callbacks instead: onSessionExpired, onTokenRefreshed, onExpiryWarning.

Changing a password

Optional. Implement changePassword on your transport and the SDK handles what happens afterwards:

createRestTransport({
  // ...
  changePassword: { path: '/api/auth/change-password' },
});
const { changePassword } = useAuth();
await changePassword({ currentPassword, newPassword });

On success the session ends by default, firing onSessionExpired. That is not tidiness: most servers invalidate the refresh token when the password changes, so the app is already holding a dead credential. Without the teardown the UI keeps working until the next refresh fires, then bounces the user with no explanation — a confusing delayed failure instead of an expected one.

If you have checked that your server keeps existing tokens valid, opt out:

config={{ endSessionOnPasswordChange: false }}

On failure nothing changes. The error is rethrown untouched and the session is left alone — the password wasn't changed, so the session is still valid. Throw AuthTransportError with a code so a form can tell "wrong current password" from "new password too weak".

Configuration

Every field is optional.

| Option | Default | What it does | |---|---|---| | storageAdapter | SplitStorageAdapter | Where tokens live — see Storage below | | storageKey | antz_auth_token_set | Base key. Change it to run two independent sessions in one app | | storageKeyPrefix | — | Required when several apps share one origin, and this is its only home — the sso config has no such field. The provider appends the mode and derives both the engine's and the SSO client's namespace from it. See Storage below | | refreshBufferSeconds | 60 | How early to refresh. Must be well under your token's lifetime | | revalidateOnFocus | true | Recheck when the tab becomes visible — both modes. RN calls this refreshOnForeground | | revalidateOnFocusThrottleSeconds | 300 | Floor between focus checks | | retryOnOnline | true | Retry a failed refresh when the network returns | | syncAcrossTabs | true | Follow logouts and refreshes from other tabs — both modes. No RN counterpart | | sessionPollIntervalSeconds | 0 (off) | Poll validateSession to catch server-side revocation | | enableExpiryCheck | false | Warn before the refresh token's hard expiry | | expiryWarningThresholds | [86400] | Seconds before expiry to warn. Pass several to warn more than once | | onSessionExpired | — | Session ended; send the user to login | | onTokenRefreshed | — | Every successful refresh — push the token to a socket client or plain fetch module | | onRefreshFailed | — | A refresh failed but the session is kept. { error, attempt } — show "reconnecting…" | | onExpiryWarning | — | Fires inside the expiry window | | endSessionOnPasswordChange | true | Log out after a successful changePassword() — see above |

Next.js notes

  • Mark the file with <AuthProvider> as 'use client' — it uses browser APIs.
  • The SDK reads localStorage, so status is idle during SSR and on the first client render. Render a loading state for idle and loading; treating them as logged-out flashes the login screen on every page load.
  • For an SSR-bootstrapped session, call setTokens() with tokens your server already holds instead of logging in again.

SSO-side options

Provider options go in the sso prop, not config. Everything the standalone WSO2 SDK accepted still works — it moved from that client's constructor to here.

Required: baseUrl, clientId, redirectUri.

| Option | Default | What it does | |---|---|---| | tenant | root org (carbon.super) | Tenant handle — "dev", "uat", "prod". Omit or pass "" for the root org | | scopes | ["openid", "profile", "email"] | OAuth2 scopes requested at /authorize | | postLogoutRedirectUri | falls back to redirectUri | Where the provider returns after logout. Must match a registered URL exactly — a trailing slash is enough to be rejected | | proxyUrl | — | Route sendOtp and changePassword through a same-origin proxy instead of calling WSO2 directly. See below | | skipVerifyAfterLoginSeconds | 5 | Grace window after a login during which the shared-session check is skipped. See below | | authorizeAppAccess | — | Your own authorization rule — see authorizeAppAccess below | | onBeforeSessionSwitch | — | Shared-session change notification — see above | | refreshBufferSeconds, sessionPollIntervalSeconds | see note | Read by the SSO client. The engine reads its own copy from config, so setting these here alone leaves the engine on its defaults — set them in config unless you specifically want the client to differ |

sso={{
  baseUrl, clientId, redirectUri,
  tenant: 'dev',
  postLogoutRedirectUri: 'https://app.example.com',
}}

proxyUrl — required for Next.js and other SSR apps. WSO2's internal REST APIs reject direct cross-origin browser calls, so sendOtp() and changePassword() fail from a Next.js app calling WSO2 straight from the browser. Point them at a same-origin route of your own that forwards the request:

sso={{ baseUrl, clientId, redirectUri, proxyUrl: '/api/auth/change-password' }}
// sendOtp()        → POST /api/auth/change-password/send-otp
// changePassword() → POST /api/auth/change-password

Omit it for a React or Vue SPA, where configuring WSO2 CORS to allow the app's origin is the simpler route.

skipVerifyAfterLoginSeconds — leave it alone unless you know you need to. Right after login(), the first authenticated page would otherwise fire a prompt=none check to verify the shared session — but WSO2's commonAuthId cookie from the login that just completed is not guaranteed to be visible to that immediate follow-up, so it can come back login_required and wipe the tokens login() just stored. This window suppresses that check. A session proven to exist moments ago cannot have been logged out elsewhere in the same instant, so nothing is missed. Tracked per browser tab. Raise it only if you see tokens cleared immediately after a successful sign-in on a slow connection.

There is no theme option on web: this package renders no built-in UI. The React Native package has one, for its password-recovery modal.

Request timeout

Every request this transport makes is aborted after timeoutMs (default 15000):

createRestTransport({ baseUrl, login, refresh, timeoutMs: 20_000 })

This matters more than it looks. Without a timeout, a refresh on a stalled network can hang indefinitely and every queued API call waits behind it. The abort surfaces as an AbortError, which isRetryable() treats as transient — so the session survives and the next attempt retries, rather than the user being signed out because their train went into a tunnel.

Headers

Two ways, and they compose.

Static — on every request. For values fixed at startup: an API key, a client identifier, a tenant.

createRestTransport({
  baseUrl,
  headers: {
    'X-Client-Platform': 'android',
    'X-Client-App': 'antz-mobile',
  },
  refresh: { ... },
});

Dynamic — per request. onRequest runs before every call and returns a partial fetch init to merge. It knows which operation is running, so headers can vary by endpoint:

onRequest: ({ operation }) => ({
  headers: {
    'X-Request-Id': crypto.randomUUID(),   // changes every call
    'X-Zooid': currentZooid(),             // changes at runtime
    ...(operation === 'refresh' ? { 'X-Retry': '1' } : {}),
  },
}),

They merge in this order, each overriding the one above:

| | Source | |---|---| | 1 | Content-Type: application/json | | 2 | your static headers | | 3 | Authorization: Bearer … (the SDK) | | 4 | onRequest headers |

onRequest last is deliberate: it can replace Authorization outright if your API uses a scheme this transport doesn't know.

onRequest: () => ({ headers: { Authorization: `MyScheme ${customToken()}` } }),

It can also return anything else fetch accepts — credentials: 'include' for cookie-based endpoints, a custom mode, and so on. And throwing from it aborts the request before fetch runs, which is where a signing or attestation step belongs:

onRequest: async () => {
  await ensureDeviceAttested();          // throws → the request never leaves
  return { headers: { 'X-Device': deviceId } };
},

There is no per-endpoint headers field. Branch on operation inside onRequest instead — one place to look rather than five.

Re-checking a password

Some flows ask someone to prove who they are before a sensitive change — a forgotten passcode, a password change. verifyCredentials() answers "is this password correct" without starting a session:

const { verifyCredentials, capabilities } = useAuth();

if (capabilities.verifyCredentials) {
  const { valid, reason, requiresAdditionalSteps } = await verifyCredentials({
    password,
  });

  if (valid) proceed();
  else if (requiresAdditionalSteps) askForOtp();   // password right, MFA needed
  else showError(reason);
}

A wrong password resolves { valid: false } — it does not throw, and it does not disturb the current session. Only genuine failures (network, 5xx) reject.

Pass only the password. The SDK fills in the signed-in user's identity, which the two modes need differently: SSO's App-Native flow submits a username and password to the provider, while a non-SSO verify endpoint identifies the user from the bearer and takes only the password. One call site works in both.

await verifyCredentials({ password });              // usual case
await verifyCredentials({ username, password });    // override if you must

It uses user.username, falling back to user.email. If the session carries neither, SSO returns { valid: false } with a reason telling you to pass one explicitly. On web, capabilities.verifyCredentials is false in SSO mode: the provider owns the login page, so the SDK never sees the password. It is true in non-SSO whenever your transport implements it.

Non-SSO — point it at a dedicated endpoint, not your login route. Logging in again mints a session that then has to be torn down, and with rotating refresh tokens a failed teardown leaves an orphan behind.

verifyCredentials: {
  path: '/api/v2/auth/verify-password',
  body: (creds) => ({ password: (creds as { password: string }).password }),
  map: (res) => {
    const r = res as { valid?: boolean; message?: string };
    return { valid: r.valid === true, reason: r.valid ? undefined : r.message };
  },
},

Two things the endpoint should do, and both are security properties:

  • Answer 200 for a wrong password, with valid: false in the body. A 401 reads as a dead credential to isRetryable() and would end the very session the user is re-authenticating within.
  • Be authenticated, so the body carries only the password. An unauthenticated variant taking an email lets anyone probe any account — and it returns no user data, so there is nothing to learn from guessing.

It also wants rate limiting: it accepts password guesses by design, without the protections your login route has.

Which mode is active, and remembering it

The chosen mode is persisted in localStorage, so a reload or a new tab reopens in whatever the user last selected. Reading it is synchronous, so unlike the React Native package there is no "not known yet" state to render around:

const { mode, switchMode, canSwitchTo } = useAuth();
const mode2 = useAuthMode();   // same value, without the rest of the surface

Outside React — a route guard, a plain module, the callback route before anything has mounted — readMode() returns it synchronously:

import { readMode } from '@antzsoft/auth-web';
if (readMode() === 'sso') { /* … */ }

Server-rendered apps need one extra step. The server cannot read localStorage, so it renders the default mode while the client renders the persisted one — and any markup branching on the mode then differs between them. Gate that UI on useIsModeReady(); see Server-rendered apps above.

Switching persists the new mode before logging out of the old one, because an SSO logout navigates the browser away — writing afterwards would mean the app came back in the mode it had just left.

Reading token expiry

Both expiries are on the token set — no extra call, and the same in either auth mode:

const { tokens } = useAuth();

tokens.expiresAt         // access token,  epoch ms — always present
tokens.refreshExpiresAt  // refresh token, epoch ms — see the caveat below

Use these for a "session ends in N days" line, a countdown, or a debug screen. There is no getSessionInfo() to call: where the value comes from differs by mode, and the SDK normalises it before you see it.

| Mode | Where refreshExpiresAt comes from | |---|---| | Non-SSO | Your map reads it straight off the login/refresh response | | SSO | Fetched from the provider, because the refresh token is opaque and cannot be read locally |

The caveat, and it only affects SSO: refreshExpiresAt is populated in SSO mode only when enableExpiryCheck: true. That lookup costs one extra call per refresh, so apps that never read the value do not pay for it. Without the flag the field is undefined in SSO — and always present in non-SSO, where it arrives free with the token response.

config={{
  enableExpiryCheck: true,   // needed for refreshExpiresAt in SSO mode
}}

expiresAt needs no flag in either mode.

One thing these values are not: a live server read. They are what the last login or refresh reported, which is what you want for display. If a session's expiry can change server-side between refreshes, treat them as a good estimate rather than authoritative.

Errors you can catch

Every error class carries an end-user-safe .message, so showing it directly is always acceptable. Catch a specific class only when you want your own wording or a different action — a "switch account" button, say.

All are exported from the package root:

import {
  AntzSessionUserMismatchError,
  AntzAuthorizeRejectedError,
  AntzAppAccessDeniedError,
  AuthTransportError,
} from '@antzsoft/auth-web';

The three session-conflict cases. These are SSO-specific and the ones most worth handling by hand, because the provider's session is shared across every app in the browser:

| Class | What happened | Useful fields | |---|---|---| | AntzSessionUserMismatchError | The browser's session belongs to a different user than the app expected | expectedUser, sessionEmail, sessionUsername | | AntzAuthorizeRejectedError | /authorize refused — either another account is signed in, or this user isn't provisioned for this app | expectedUser, wso2Error, wso2ErrorDescription, sessionExisted | | AntzAppAccessDeniedError | The silent cross-app check found a session with no access to this app | rejectedUser |

AntzSessionUserMismatchError only fires if you asked for a specific user. The check compares the returned subject against an expected user, and the expected user is recorded only when you pass a loginHint:

// Requires this account: a live session for anyone else is a mismatch.
await login({ loginHint: '[email protected]' });

// No hint → no expected user → any live session is silently reused.
await login();

// The escape hatch from a mismatch: ignore any session, always show the form.
await login({ loginHint: '[email protected]', prompt: 'login' });

So an app that wants this protection needs somewhere for the user to say who they are — a username field on the sign-in screen, or a value it already holds. All four *-dual-auth sample apps show the field-based version.

AntzAuthorizeRejectedError deserves a note. Those two causes are genuinely indistinguishable from the response — no token is issued either way, so there is no id_token to read an identity from. The SDK infers it from whether the browser detoured through the provider's hosted login page, and exposes the answer as sessionExisted: true | false | undefined. Its .message only claims "another account is signed in" when that is confidently true; otherwise it uses generic "you don't have access" wording. Treat undefined as "can't tell".

The rest, mostly self-explanatory: AntzInvalidCredentialsError, AntzSessionExpiredError, AntzPasswordPolicyError, and four separate OTP cases — AntzOtpRequiredError, AntzInvalidOtpError, AntzOtpExpiredError, AntzOtpMaxAttemptsError — so a screen can say which OTP problem occurred.

Both modes: AuthTransportError carries httpStatus, which is what isRetryable() reads — 5xx, network errors and 429 keep the session and retry; 4xx ends it. AuthNotSupportedError means the active mode cannot do what was asked; check capabilities first.

One catch for everything. All SSO error classes extend AntzAuthError, so a single instanceof covers them when you only need to separate "the SDK said no" from a bug in your own code:

import { AntzAuthError } from '@antzsoft/auth-web';

try { await login({ username, password }); }
catch (err) {
  if (err instanceof AntzAuthError) setError(err.message);  // safe to display
  else throw err;                                           // not ours — let it surface
}

AntzTokenError (token exchange or refresh failed) and AntzApiError (a provider REST call failed, carrying status) sit under it too, for the rarer case of telling transport-level trouble from an auth decision.

Customizing error messages

Catch the class, read its fields, write your own text:

try {
  await client.handleCallback();
} catch (err) {
  if (err instanceof AntzAppAccessDeniedError) {
    setError(`${err.rejectedUser.email} is not authorized for this application.`);
  } else if (err instanceof AntzSessionUserMismatchError) {
    setError(`You're signed in as ${err.sessionEmail} elsewhere — log out there first.`);
  } else {
    setError('Sign-in failed. Please try again.');
  }
}

On the silent cross-app path there is no exception to catch — nothing was awaited. The outcome arrives as a reason on onBeforeSessionSwitch, and the ready-made copy comes from client.consumeSilentLogoutMessage(). Branch on reason, never on the text:

onBeforeSessionSwitch: async ({ reason, previousUser, newUser }) => {
  switch (reason) {
    case 'app_access_denied':
      showToast("This account isn't provisioned for this app."); break;
    case 'app_rejected_by_own_policy':
      showToast(`${newUser?.email ?? 'This user'} isn't authorized here yet.`); break;
    case 'different_user':
      showToast(`Switched to ${newUser?.email}.`); break;
    case 'no_session':
      showToast('Signed out in another tab.'); break;
  }
},

Session callbacks

All of these live on config and fire identically in both auth modes — they belong to the engine, which is the same code either way:

config={{
  onSessionExpired: () => router.push('/login'),
  onTokenRefreshed: (tokens) => socket.setToken(tokens.accessToken),
  onRefreshFailed:  ({ error, attempt }) => showBanner(`Reconnecting… (${attempt})`),
  onExpiryWarning:  ({ threshold }) => warnUser(threshold),
}}

| Callback | Fires when | Session | |---|---|---| | onSessionExpired | The session ended and cannot be recovered | gone — send the user to login | | onTokenRefreshed | Every successful refresh | alive — push the new token wherever you cache it | | onRefreshFailed | A refresh failed transiently | kept — show "reconnecting", do not log out | | onExpiryWarning | A configured threshold is crossed | alive — see Expiry warnings |

The split between the middle two is the one to get right: onRefreshFailed means a network blip or a 5xx and the session survives, while onSessionExpired means a dead credential and it does not. Treating the first as a logout signs users out during an outage.

Do not look for these on the sso prop. They used to be declared there too and never fired — the code that called them belonged to the standalone WSO2 SDK's own bindings, which this package replaces. They are gone from that config now, so passing one is a type error rather than silence.

onBeforeSessionSwitch — SSO only

One callback genuinely has no non-SSO counterpart, because it is about the browser's shared provider session rather than this app's:

sso={{
  baseUrl, clientId, redirectUri,
  onBeforeSessionSwitch: async ({ reason, previousUser, newUser }) => {
    // Runs BEFORE this app's stale tokens are cleared, so you can tear down
    // your own server-side state for the outgoing user first.
    await fetch('/api/session/clear', { method: 'POST' });
  },
}}

reason distinguishes the cases: no_session (signed out everywhere), different_user (someone else signed in through another app on this browser), app_access_denied (a live session exists but is not authorized for this app).

It is awaited, so a slow teardown delays the switch rather than racing it. There is nothing equivalent in non-SSO: no session is shared between apps, so nothing can change underneath you.

Per-component, with useSharedSessionChanged(). Config takes one app-wide handler. When a component wants its own — clear a query cache, close a socket — subscribe instead, and the subscription lives and dies with the component:

import { useSharedSessionChanged } from '@antzsoft/auth-web/react';

useSharedSessionChanged(({ reason, previousUser }) => {
  if (reason === 'different_user') queryClient.clear();
});

Vue has no provider to read the client from, so pass the one createAuth() returned:

import { useSharedSessionChanged } from '@antzsoft/auth-web/vue';

const { ssoClient } = createAuth({ ... });
useSharedSessionChanged(ssoClient, ({ reason }) => { ... });

Both fire if both are set, onBeforeSessionSwitch first, then each listener in registration order. A listener that throws is logged and skipped, so one component's bug cannot block the session change. In non-SSO the hook is inert rather than an error, so a dual-mode component can call it unconditionally.

Migrating from @antzsoft/wso2-auth-web? This is the same event its React/Vue adapters exposed as an onSharedSessionChanged hook option. The spelling changed — a hook of its own rather than an option on useAntzAuth() — because this package's bindings take config on the provider.

authorizeAppAccess — your own authorization rule — SSO only

WSO2 answers "is this a valid user?". Whether that user may use this app is often a question only your app can answer: a role, a group, a feature flag, a check against your own backend.

sso={{
  baseUrl, clientId, redirectUri,
  authorizeAppAccess: async (user, { source }) => {
    if (source === 'silent') return user.groups?.includes('fleet-ops') ?? false;
    const res = await fetch(`/api/authz?sub=${user.sub}`);
    return res.ok;
  },
}}

It runs after WSO2 issues a real token — login succeeded, and for a hinted login the identity matched — but before that token is stored or treated as a session by anything. Return false and the token never lands: the SDK rejects with AntzAppAccessDeniedError, carrying the rejectedUser you turned away.

source says which path asked. 'manual' is a login the user just performed; 'silent' is the cross-app check on a shared session. Distinguishing them matters when the check costs a round trip — a silent check runs on page load, so many apps do something cheap there and the full lookup only on 'manual'.

Because it is awaited, a slow rule delays sign-in. Keep it fast, and prefer claims you already have over a network call.

autoSwitchAccount — recover from a mismatch without an error — SSO only

By default, asking for a specific account with loginHint while the browser holds a session for someone else throws AntzSessionUserMismatchError — the app decides what to tell the user. autoSwitchAccount: true makes the SDK recover on its own instead:

await login({ loginHint: '[email protected]', autoSwitchAccount: true });

It re-issues login({ loginHint, prompt: 'login' }), so WSO2 ignores the existing session and shows its form for the account you asked for. Only meaningful with a loginHint — without an expected user there is no mismatch to detect, and the option is ignored.

It never re-arms: if the forced attempt also comes back as the wrong user, the error throws rather than looping. Use it when the app already knows who should be signing in; leave it off when the user picked the account themselves and deserves to be told what happened.

Expiry warnings

The refresh token has its own expiry. When it passes, the session ends and re-login is unavoidable — so the useful thing is advance notice.

Pass several thresholds to warn more than once as expiry approaches. One option, one callback; the callback is told which threshold fired, so a single handler can escalate:

config={{
  enableExpiryCheck: true,
  expiryWarningThresholds: [86_400, 3_600, 300],   // a day, an hour, 5 min

  onExpiryWarning: ({ threshold, secondsRemaining, expiresAt, tokens }) => {
    if (threshold <= 3_600) showModal(`Signing out in ${secondsRemaining}s`);
    else                    showBanner('Your session ends today');
  },
}}

A single number works too — expiryWarningThresholds: 3_600. The default is [86400] (24 hours).

Each threshold fires once per session. A threshold already passed when the app opens fires on startup, so a device closed through the moment still gets the warning — with one exception: a session that began after the threshold is skipped, since a freshly issued token cannot be near expiry.

The SDK does NOT log anyone out. The callback decides whether to prompt, badge, or ignore. Auto-logout on actual expiry is separate and always on, in both modes.

Needs refreshExpiresAt on the token set; silently inert without it. Non-SSO gets it from your map; in SSO the SDK fetches it from the provider — which costs one extra call per refresh, so it only happens when enableExpiryCheck is on.

Storage

Split by default

SplitStorageAdapter puts the two halves of a session in different places, because they have genuinely different lifetimes:

| Store | Holds | Why | |---|---|---| | sessionStorage | access token, its expiry | Short-lived and tab-scoped. Cleared when the browser closes, limiting how long an XSS payload can find a usable access token. | | localStorage | refresh token, its expiry | Must survive a browser close, or reopening the app forces a login even though the refresh token is still valid. |

On a browser reopen the session half is gone but the refresh half remains: the SDK returns an already-stale access token so its restore path refreshes immediately. That is the whole point of the split.

Stated plainly: the refresh token is the long-lived credential and it does live in localStorage. Keeping the access token out reduces what an attacker gains — it does not make it safe.

Alternatives, all exported:

| Adapter | Behaviour | |---|---| | SplitStorageAdapter | Default. As above. | | localStorageAdapter | Everything survives browser close. Simpler, weaker. | | sessionStorageAdapter | Nothing survives browser close. Strictest built-in. | | createMemoryStorageAdapter() | Nothing survives a reload. Tests, SSR, and the cookie pattern below. |

For a real guarantee, keep the refresh token in an httpOnly cookie owned by your own backend and pass createMemoryStorageAdapter() here — the SDK then holds only an in-memory access token and your backend performs the refresh.

Several apps on one origin

Browser storage is scoped to the origin, not the path. Two apps behind one host — /d/analytics and /d/reports — share the same localStorage, so without a namespace they read and clear each other's tokens: one logging out logs the other out, and a refresh in one can hand the other an already-rotated token.

storageKeyPrefix fixes that. Give each app a unique value:

<AuthProvider transport={transport} config={{ storageKeyPrefix: 'analytics' }}>

Keys become analytics_direct_antz_auth.* / analytics_sso_antz_auth.*, and clear() is scoped to that prefix, so signing out of one app leaves the others alone. The cross-tab storage listener matches on the prefixed keys too, so a sibling app's writes don't trigger your reload.

Pass it once, on config. The provider appends the mode itself and derives both namespaces from that one value — the engine's and the SSO client's. Do not add a mode segment of your own (analytics-sso would become analytics-sso_sso_…), and note the SSO config has no storageKeyPrefix field at all — the provider passes the client's namespace as a constructor argument, so config is the only place it can be said. The two halves must namespace storage identically: when the prefix could be set in two places they came to disagree, the client writing tokens under one prefix while the engine looked under another, so sign-in succeeded and the app still showed the login screen.

Without a prefix the namespaces are the bare sso_ and direct_, which are not app-specific — two apps sharing an origin then collide. Set it whenever more than one app can be served from the same host.

decodeJwt does not verify signatures, and cannot: a browser can't hold a verification key safely. Decoded claims are for display and scheduling only. Every authorisation decision belongs on the server.

API reference

Hooks (/react) — useAuth(), useAccessToken(), useAuthMode(), useIsModeReady() (gate mode-dependent UI on this in server-rendered apps) Provider (/react) — AuthProvider EngineSessionManager (framework-free) TransportcreateRestTransport(), AuthTransport, AuthResult, TokenSet, AuthUser ErrorsAuthTransportError, AuthNotSupportedError, isRetryable(), isNetworkError() StorageSplitStorageAdapter (default), localStorageAdapter, sessionStorageAdapter, createMemoryStorageAdapter(), createTokenStorage(), storageKeysFor(), StorageAdapter JWTdecodeJwt(), expiryFromJwt(), jwtToUser() ExpiryisAccessTokenExpired(), isRefreshTokenExpired(), msUntilRefresh(), expiresAtFromTtl()

Local development

npm install
npm run build

# Publish to a local Verdaccio registry (see .npmrc)
npx verdaccio &
npm adduser --registry http://localhost:4873
npm publish --registry http://localhost:4873

Five files under src/transport/ and src/utils/ are behaviourally identical with the React Native package. sdks/scripts/check-shared.sh enforces that, and also greps both packages for provider-specific vocabulary. Run it after touching either.

Full integration guide

Step-by-step, with every option and the reasoning behind the defaults:

See also

Runnable samples — each runs this SDK and the WSO2 one side by side, switchable at runtime, against mock-auth-server: