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

@hyper.authio/bff

v0.9.0

Published

Backend-for-Frontend for Authio on Express: server-side sessions, login/logout/user endpoints, anti-forgery, token refresh, remote API proxy and back-channel logout. Tokens never reach the browser.

Readme

@hyper.authio/bff

A Backend-for-Frontend for Authio on Express 4 and 5. It lets a browser SPA (React, Angular, Vue, plain JavaScript…) sign in with Authio without ever holding a token, following the BFF pattern of the IETF draft OAuth 2.0 for Browser-Based Applications. The Node counterpart of Authio.AspNetCore.Bff: same endpoints, same cookie, same anti-forgery header, same option names. Built on @hyper.authio/client.

npm install @hyper.authio/bff

Why a BFF

Tokens kept in the browser (local storage, session storage, memory) can be stolen by any script that runs on the page, and refresh tokens outlive the page. With a BFF:

  • the SPA and the BFF share an origin; the browser only holds an HttpOnly, Secure, SameSite=Lax, host-only cookie (__Host-authio) that carries a signed random session id and nothing else;
  • the BFF is a confidential client: it runs the authorization code flow with PKCE, keeps the access, refresh and ID tokens server-side (encrypted in the session store), refreshes them, and attaches the access token when it calls APIs on the person's behalf;
  • every API call from the SPA carries a custom header (X-CSRF: 1), which a cross-site page cannot add without a CORS preflight, so the cookie cannot be abused by CSRF.

Register the client at Authio

  • Type: confidential, with a secret (client_secret_basic by default, or tokenEndpointAuthMethod: "client_secret_post"), or with private_key_jwt (clientPrivateKey or clientPrivateKeyPath, and clientKeyId).
  • PKCE: required with S256 is the recommended setting, mirrored by checks: ["pkce", "state", "nonce"] and codeChallengeMethod: "S256". The BFF checks only what checks lists: left out, it checks nothing.
  • Redirect URI: https://<your-app>/signin-oidc (callbackPath).
  • Post-logout redirect URI: https://<your-app>/signout-callback-oidc (signedOutCallbackPath).
  • Back-channel logout URI: https://<your-app>/bff/backchannel-logout.
  • Front-channel logout URI (optional, with frontChannelLogout: true): https://<your-app>/bff/frontchannel-logout, with the session required so Authio sends iss and sid.
  • Scopes: openid profile email offline_access (offline_access gives the refresh token), plus roles if you set requestRoles, plus your API scopes.
  • Resources (optional): the API resource identifiers you ask for with resource or use with remote APIs; allow the token exchange grant if a remote API needs a token for a specific resource or a narrower scope set.

Setup

import express from "express";
import { authioBff } from "@hyper.authio/bff";

const app = express();

const bff = authioBff({
  authority: "https://auth.example.com",
  realm: "acme",
  clientId: "acme-web",
  clientSecret: () => process.env.AUTHIO_CLIENT_SECRET!, // read on every call: a rotated secret needs no restart
  checks: ["pkce", "state", "nonce"], // the client requires PKCE with S256: mirror it, nothing is checked by default
  codeChallengeMethod: "S256",
  cookieSecret: process.env.BFF_COOKIE_SECRET!, // 32+ characters; an array rotates them
  requestRoles: true,
  resource: "https://orders.example.com", // the session's token is addressed to the orders API
});

app.use(bff); // /bff/login, /bff/logout, /bff/user, /bff/silent-login, /bff/backchannel-logout, /bff/frontchannel-logout, /signin-oidc, /signout-callback-oidc

// Remote APIs: mount them before any body parser, so the body is streamed through.
app.use("/api/orders", bff.remoteApi("https://orders.example.com"));
app.use("/api/inventory", bff.remoteApi("https://inventory.example.com", { tokenMode: "clientCredentials", scopes: ["inventory:read"] }));
app.use("/api/billing", bff.remoteApi("https://billing.example.com", { resource: "https://billing.example.com" }));

app.use(express.json());

// Local APIs: the anti-forgery header and a session are required; req.authioBff holds the session, never a token.
app.get("/api/me", bff.api(), (req, res) => res.json({ name: req.authioBff!.claims.name }));

app.get("/api/report", bff.api(), async (req, res) => {
  const reports = bff.userFetch(req, res, { resource: "https://reports.example.com" });
  const answer = await reports("https://reports.example.com/monthly");

  res.status(answer.status).json(await answer.json());
});

app.use(express.static("dist")); // the SPA

app.listen(5000);

app.set("trust proxy", 1) behind a reverse proxy, so the redirect URIs are built with https; or set redirectUri and postLogoutRedirectUri explicitly.

The SPA side

const api = (url, init = {}) => fetch(url, { ...init, credentials: "same-origin", headers: { ...init.headers, "X-CSRF": "1" } });

const response = await api("/bff/user");

if (response.status === 401) {
  window.location.href = "/bff/login?returnUrl=" + encodeURIComponent(location.pathname);
} else {
  const claims = await response.json();
  const logoutUrl = claims.find((claim) => claim.type === "bff:logout_url").value;
}

| Endpoint | Behaviour | | --- | --- | | GET /bff/login | Starts the sign-in. returnUrl (local only, else 400), organization, prompt (none, login, select_account, consent), login_hint, ui_locales, max_age, resource (must be resource or one of allowedResources, else 400 invalid_target). A failure comes back as returnUrl?error=<code> (access_denied, login_required, login_failed…). | | GET /signin-oidc | The callback: finds the sealed sign-in cookie (by state with the state check), checks the RFC 9207 iss and the configured checks, redeems the code (with the PKCE verifier with pkce), validates the ID token (nonce with nonce, at_hash), reads the access token's claims once its signature verifies, creates the session and replaces any previous one. | | GET /bff/user | With X-CSRF: 1: [{ "type", "value" }] — the session's claims (strings, one entry per array element), one role per Authio role and one permission (resource:method) per grant from the *_resource_access claims, bff:session_expires_in (seconds) and bff:logout_url. 401 without the header (antiforgery_required) or without a session (unauthorized), never a redirect. | | GET\|POST /bff/logout | With the session's sid (from bff:logout_url) or X-CSRF: 1, optionally returnUrl: revokes the refresh token, deletes the session, clears the cookie and redirects to Authio's end-session endpoint with id_token_hint, post_logout_redirect_uri and a sealed state; /signout-callback-oidc then sends the browser to returnUrl. Without sid or the header, a signed-in person cannot be logged out by another site (400). | | GET /bff/silent-login | A prompt=none sign-in for a hidden iframe; /bff/silent-login-callback posts { source: "authio-bff-silent-login", isLoggedIn, error } to the parent window (same origin only). | | POST /bff/backchannel-logout | Authio's back-channel logout: validates the logout_token (validateLogoutToken) and deletes every session of that Authio session (sid), or of the person (sub) when the token names no sid. 400 invalid_request for a missing or invalid token. | | GET /bff/frontchannel-logout | With frontChannelLogout: true, Authio's front-channel logout iframe: checks iss (validateFrontChannelLogout) and deletes every session of the sid it names, or the browser's own session when it names none; answers an empty page. 400 invalid_request for another issuer or a sid without iss; 404 when turned off. |

Calling APIs

Local APIs: bff.api() answers 401 antiforgery_required without the header and 401 unauthorized without a session; bff.api({ optional: true }) lets anonymous calls through, { requireAntiForgery: false } drops the header check. bff.getUserAccessToken(req, res, { resource?, scopes? }) and bff.userFetch(req, res, …) hand the person's token to your own code.

Remote APIs: bff.remoteApi(destination, options) forwards everything below its mount path to destination, strips the browser's cookies, the anti-forgery header and any incoming Authorization, attaches the token, drops upstream Set-Cookie, streams both bodies, and answers 502 when the API cannot be reached.

| tokenMode | Token | | --- | --- | | user (default) | The person's access token; anonymous calls get 401. | | userOrNone | The person's token when signed in, otherwise none. | | clientCredentials | A client-credentials token of the BFF (cached by @hyper.authio/client), whoever is signed in. | | userOrClientCredentials | The person's token when signed in, otherwise a client-credentials token. | | none | No token. |

With resource or scopes, a user token is obtained by token exchange (RFC 8693, the result carries act) and cached per session, resource and scopes; a client-credentials token is asked for them directly. When a remote API answers 401 to a user call, the BFF introspects the session and ends it if Authio says it is gone (validateSessionOnUnauthorized). requireAntiForgery: false is meant for anonymous APIs only.

Tokens for an API protected by Authio

An API protected with @hyper.authio/express or Authio.AspNetCore accepts only tokens addressed to it (aud = the resource identifier in its audiences). Either make the session's token the API's with resource (or /bff/login?resource=…, allowed by allowedResources), or give the remote API a resource so the session's token is exchanged for one addressed to it. Keep offline_access when you set resource: the session check introspects the refresh token, which stays addressed to the BFF.

Tokens and sessions

  • Refresh: the access token is refreshed tokenRefreshSkew (60 s) before it expires, under a lock per session, so concurrent requests share one refresh (Authio rotates refresh tokens and treats a late reuse as a replay). The rotated refresh token is stored before the lock is released. A refused refresh (invalid_grant) ends the session; a network error keeps the current token while it lasts. An ID token the refresh returns is validated (signature, iss, aud, exp, at_hash, the same sub) before it is stored; one that does not validate ends the session.
  • Claims: the session's claims are the validated ID token's, plus the claims only the access token carries (*_resource_access, org_id, org_alias, account_id) once the access token's signature, iss and exp verify (client.readAccessTokenClaims), plus UserInfo's with getClaimsFromUserInfoEndpoint.
  • Session check (sessionValidation: "introspection", the default): every sessionValidationInterval (300 s) the refresh token (or the access token when there is none) is introspected; an inactive answer ends the session. Needs a confidential client.
  • Lifetime: sessionLifetime (8 h) without activity; renewed once half of it has passed (slidingExpiration). The cookie is a browser-session cookie; the server-side expiry decides.
  • Cookie: __Host-authio=<64 hex>.<HMAC-SHA256>, HttpOnly, Secure, SameSite=Lax (or strict), Path=/, no Domain. A tampered or unknown cookie is cleared.
  • Store: in memory by default. For several instances, implement AuthioBffSessionStore over a shared store; the BFF encrypts each session (AES-256-GCM, from cookieSecret) before it reaches the store and keeps only sessionId (sid), subject (sub) and expiresAt in clear, for indexing.
import type { AuthioBffSessionStore, AuthioBffStoredSession } from "@hyper.authio/bff";

const redisStore = (redis: Redis): AuthioBffSessionStore => ({
  async get(key) {
    const value = await redis.get(key);

    return value ? (JSON.parse(value) as AuthioBffStoredSession) : undefined;
  },
  async set(session) {
    const ttl = Math.max(1, Math.ceil((session.expiresAt - Date.now()) / 1000));

    await redis.multi()
      .set(session.key, JSON.stringify(session), "EX", ttl)
      .sadd(`sid:${session.sessionId}`, session.key).expire(`sid:${session.sessionId}`, ttl)
      .sadd(`sub:${session.subject}`, session.key).expire(`sub:${session.subject}`, ttl)
      .exec();
  },
  async delete(key) {
    await redis.del(key);
  },
  async deleteBySessionId(sessionId) {
    const keys = await redis.smembers(`sid:${sessionId}`);

    if (keys.length > 0) await redis.del(...keys, `sid:${sessionId}`);
  },
  async deleteBySubject(subject) {
    const keys = await redis.smembers(`sub:${subject}`);

    if (keys.length > 0) await redis.del(...keys, `sub:${subject}`);
  },
});

The refresh lock is local to the process, as in .NET: with several instances, keep a person on one instance (sticky sessions) or rely on Authio's short refresh-token reuse leeway.

Options

The client takes the names of conformance/options.md (issuer or authority + realm, clientId, clientSecret, tokenEndpointAuthMethod, clientPrivateKey, clientKeyId, clientEncryptionKey, encryptionAlgorithms, encryptionMethods, idTokenSignedResponseAlg, tokenRefreshSkew, requireHttps, clock…), or an existing client. They mirror the client as it is registered in Authio, and an option left out is off:

| In the console | Options | | --- | --- | | PKCE required, S256 | checks: ["pkce", "state", "nonce"], codeChallengeMethod: "S256" | | PKCE required, plain only | checks: ["pkce", "state", "nonce"], codeChallengeMethod: "plain" | | PKCE off | checks: ["state", "nonce"] | | client_secret_post | clientSecret, tokenEndpointAuthMethod: "client_secret_post" | | ID token signing algorithm HS256 | clientSecret, idTokenSignedResponseAlg: "HS256" | | Access token signing algorithm HS256 | clientSecret, accessTokenSignedResponseAlg: "HS256" | | Front-channel logout URI with the session required | frontChannelLogout: true |

checks, codeChallengeMethod and responseMode (only query: the BFF reads the callback itself) are read even with an existing client. The BFF's own options are those of Authio.AspNetCore.Bff (Authio:Bff), in camelCase, with durations in seconds:

| Option | Default | | | --- | --- | --- | | cookieSecret | — (required) | 32+ characters; an array rotates: the first protects, every one is accepted. | | store | in memory | AuthioBffSessionStore. | | endpointPrefix | /bff | Where the BFF endpoints are served. | | scopes | openid profile email offline_access | Sent as configured; openid is not added. Without it there is no ID token, and the session is built from the verified access token. | | requestRoles | false | Adds roles, so the session carries *_resource_access. | | getClaimsFromUserInfoEndpoint | false | Adds UserInfo's claims to the session. | | organization, resource, prompt, uiLocales, maxAge | — | Sent on every sign-in unless the login request names one. | | allowedResources | [] | What /bff/login?resource= may ask for. | | additionalAuthorizationParameters | {} | Extra authorize parameters. | | requireIssuerParameter | true | Refuses a callback without iss (RFC 9207). | | callbackPath, signedOutCallbackPath | /signin-oidc, /signout-callback-oidc | Registered at Authio. | | redirectUri, postLogoutRedirectUri | built from the request | Absolute URIs, when the request's origin is not the public one. | | defaultReturnUrl | / | Local only. | | cookieName, cookieSameSite | __Host-authio, lax | The name must start with __Host- or __Secure-. | | sessionLifetime, slidingExpiration | 28800, true | | | sessionValidation, sessionValidationInterval | introspection, 300 | none turns the check off. | | revokeRefreshTokenOnLogout | true | | | antiForgeryHeaderName, antiForgeryHeaderValue | X-CSRF, 1 | | | sessionKeyPrefix | authio-bff: | The prefix of the store keys. | | backChannelLogout | true | Serves /bff/backchannel-logout. | | frontChannelLogout | false | Serves /bff/frontchannel-logout. | | onError | — | Errors the BFF recovers from (failed revocation, introspection or refresh, refused logout token or ID token, an access token that does not verify, failed sign-in). |

Every invalid option is named at once, in one AuthioConfigurationError, when authioBff() runs.

Differences from Authio.AspNetCore.Bff

  • cookieSecret is required: ASP.NET Core protects the cookie and the stored tickets with Data Protection, which Node does not have. It signs the cookie (HMAC-SHA256) and encrypts the stored sessions and the sign-in cookie (AES-256-GCM).
  • The session store is this package's own interface (indexed by sid and sub, so back-channel logout deletes the sessions) rather than IDistributedCache with logout markers.
  • AsAuthioBffApiEndpoint()/[AuthioBffApi] + RequireAuthorization() are one middleware, bff.api(); AddAuthioBffUserToken() on an HttpClient is bff.userFetch(); MapAuthioRemoteApi is bff.remoteApi() mounted with app.use. There is no 403 access-denied answer: Express has no authorization policies to refuse with.
  • YARP's per-request settings (RequestConfig, HttpClientName) become a fetch option on the remote API.