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/server

v0.10.0

Published

Protect a Node.js API with Authio: token validation (JWT or introspection), the caller with every form of permission (scopes, roles and permissions by source, groups, claims, organization, acr/amr), authorization rules, webhooks, forwarding and on-behalf-

Readme

@hyper.authio/server

Protect a Node.js API with Authio, in any framework: token validation, the caller with every form of permission Authio issues, authorization rules, a webhook receiver, and calls to downstream APIs. For Express, use @hyper.authio/express, built on this package.

npm install @hyper.authio/server

Validate tokens

import { createAuthioVerifier, AuthioBearerError } from "@hyper.authio/server";

const verifier = createAuthioVerifier({
  issuer: "https://auth.example.com/realms/acme",
  audiences: "https://orders.example.com", // the API's resource identifier (RFC 8707)
});

try {
  const user = await verifier.verifyAuthorizationHeader(request.headers.authorization);
} catch (error) {
  if (error instanceof AuthioBearerError) {
    response.setHeader("WWW-Authenticate", error.wwwAuthenticate);
    response.statusCode = 401;
  }
}

| Option | Default | | | --- | --- | --- | | audiences | | Required, unless resource is set. | | resource | | The API's own resource identifier (RFC 8707); added to audiences and used for RFC 9728. | | resourceMetadata | off | true (or the URL) adds resource_metadata="{origin}/.well-known/oauth-protected-resource{path}" to 401 challenges (RFC 9728). | | requiredScopes | | Scopes every request must carry (all); authioBearer answers 403 insufficient_scope. | | clientEncryptionKey, clientEncryptionKeyPath | | Decrypts access tokens Authio encrypts to this key and validates them locally. Without it nothing is decrypted. RSA1_5 is refused (legacy_algorithm), even with allowLegacyEncryptionAlgorithms. | | encryptionAlgorithms, encryptionMethods | RSA-OAEP, RSA-OAEP-256; the CBC-HS methods | The JWE alg and enc accepted, as registered in Authio (AES-GCM may be listed; RSA1_5 cannot: Node cannot decrypt it). | | introspection | none | none: local JWT validation; the caller is read from the token alone. activeCheck: then confirmed active by introspection (cached per jti), so a revoked token, an ended session or a suspension is refused before the token expires; the caller is read from the token, plus the claims of the answer the token does not carry. introspectionOnly: introspection alone, for opaque (Reference) tokens (or encrypted ones without the key); the caller is read from the answer. A Lightweight access token needs activeCheck or introspectionOnly (see below). | | client | | The API's confidential client, needed by introspection: an AuthioClient or { clientId, clientSecret } (or a private key), with tokenEndpointAuthMethod as the client is registered. It must be the resource server's introspection client. | | introspectionCacheDuration | 30 s | Never past the token's expiry; only active answers are kept. | | clockSkew | 30 s | | | accessTokenSignedResponseAlg | RS*, PS*, ES* (never HS*) | The algorithm (or several) access tokens are signed with, as the client is registered in Authio. HS256/384/512 are verified with the clientSecret of client: { clientId, clientSecret } (at least 32, 48 or 64 bytes). | | rejectNonAccessTokens | true | Refuses a token whose typ claim is not Bearer (an ID token, for one). The header typ must be at+jwt (RFC 9068 §4) regardless. | | roleClaims | ["role"] | Claims holding flat role names (a Role claim mapper). | | groupClaims | ["groups"] | Claims holding group names (a Group claim mapper). |

A refusal is an AuthioBearerError whose wwwAuthenticate is Bearer when the request had no token, and Bearer error="invalid_token", error_description="…" otherwise (plus resource_metadata when turned on). Its reason names why, with the outcome names of conformance/tokens.json (expired, invalid_audience, not_access_token, reference_token, decryption_failed, …, and inactive / unconfirmed for introspection). A token addressed elsewhere is described with the audience the API accepts; introspection failures fail closed.

Lightweight and reference access tokens

Authio hands out a client's access tokens in the form its access token format setting names:

| Format | The token | Validate it with | | --- | --- | --- | | Jwt | A self-contained JWT with every claim. | Any mode. | | Lightweight | A JWT with only the identity and session claims (iss, sub, aud, exp, nbf, iat, jti, typ, client_id, azp, scope, sid, auth_time, acr, amr, idp, act, delegation_id, session_client, org_id, org_alias). The *_resource_access roles and permissions, subscription, the profile and the email are only in the introspection answer. | activeCheck or introspectionOnly, which read the caller with the answer's claims, so roles, permissions, kind and email are complete. With none the caller has only what the token carries: no role or permission, and a consumer reads as a client. | | Reference | An opaque handle, no JWT (refused locally as reference_token). | introspectionOnly. |

const verifier = createAuthioVerifier({
  issuer,
  resource: "https://orders.example.com",
  introspection: "activeCheck", // the client's access token format is Lightweight
  client: { clientId: "orders-api", clientSecret: process.env.AUTHIO_CLIENT_SECRET! },
});

Nothing tells a lightweight token apart from a full one that carries no role (a person without roles has no *_resource_access either), so the verifier never guesses: the mode decides. The introspection answer is cached per jti for introspectionCacheDuration, so activeCheck asks Authio once per token and cache period. The answer only adds claims: one the token carries keeps the token's value, which its signature vouches for (Authio answers a single aud, and client_id for azp).

Protected resource metadata (RFC 9728)

import { createProtectedResourceMetadata, protectedResourceMetadataPath } from "@hyper.authio/server";

const verifier = createAuthioVerifier({ issuer, resource: "https://orders.example.com/api", resourceMetadata: true });
const path = protectedResourceMetadataPath(verifier.resource!); // /.well-known/oauth-protected-resource/api
const document = createProtectedResourceMetadata({ issuer, resource: verifier.resource!, scopesSupported: ["orders:read"] });

Access tokens signed with the client secret (HS*)

Authio signs a client's access tokens with the algorithm of its access token setting. When that is HS256, HS384 or HS512, the signature is made with the client secret: only who holds the secret validates the token locally, so that is the client's own API.

const verifier = createAuthioVerifier({
  issuer,
  audiences: "storefront",
  accessTokenSignedResponseAlg: "HS256",
  client: { clientId: "storefront", clientSecret: process.env.AUTHIO_CLIENT_SECRET! }, // 32+ bytes for HS256
});

Third-party APIs never get the secret: they validate such tokens with introspection (introspection: "introspectionOnly" and their own introspection client). Without accessTokenSignedResponseAlg, an HS* token is refused (invalid_algorithm) with a description naming both ways. authioBearer and the MCP packages take the same option.

The caller

AuthioUser reads every form of permission, the same way from a JWT, an introspection answer or both (activeCheck):

user.subject; user.clientId; user.scopes;
user.kind;           // "user" | "client" | "consumer" | "account_credential"
user.credentialForm; // "client_secret" | "personal_access_token" | "api_key" | "consumer_api_key" (machines only)
user.consumerName; user.credentialId; user.credentialName; user.ownerUserId; user.ownerUsername; user.accountId;
user.credentialRealm; user.credentialClient;          // what an account credential is limited to
user.impersonated; user.impersonator;                 // an administrator acting as the person (act.sub)
user.delegated; user.delegationId; user.actorChain;   // clients acting for the person (act.client_id), outermost first
user.authTime;       // auth_time, in seconds
user.roles;          // *_resource_access roles, then flat role claims
user.permissions;    // { orders: ["read", "write"] }
user.resourceAccess; // per-source entries: user, realm, client, plan, consumer, secret, system
user.groups;         // from the group claims
user.organizationId; user.organizationAlias; user.authenticationLevel; user.authenticationMethods; user.actor;
user.claims;         // every claim, for claim-mapper claims and the person's own claims

user.hasPermission("orders", "read");
user.hasPermission("orders", "read", { sources: ["realm", "client"] });
user.hasRole("admin", { sources: ["user"] });
user.hasGroup("finance");
user.hasClaim("department", "finance", "sales");

The raw token is kept as user.token, not enumerable, so it stays out of logs and JSON. The caller fields follow conformance/callers.json; AuthioUser.fromIntrospection(answer) reads an introspection answer the same way.

Authorize

import { authorize, requirePermission, requireGroup, requireAuthenticationLevel, allOf, anyOf, AuthioForbiddenError } from "@hyper.authio/server";

const refusal = authorize(user, requirePermission("orders", "write"), anyOf(requireGroup("finance"), requireAuthenticationLevel(2)));

if (refusal) {
  const error = new AuthioForbiddenError(refusal);
  // error.status: 401 when the refusal asks for a stronger or more recent sign-in (insufficient_user_authentication,
  // RFC 9470 §3), 403 otherwise; error.wwwAuthenticate for scope (insufficient_scope) and sign-in refusals;
  // toProblemDetails(error) for an RFC 9457 body with the same status.
}

| Refusal | Status | WWW-Authenticate | | --- | --- | --- | | No token | 401 | Bearer (no error, RFC 6750 §3.1) | | Token not valid | 401 | Bearer error="invalid_token", error_description="…" | | Missing scope | 403 | Bearer error="insufficient_scope", error_description="…", scope="…" | | Sign-in too weak or too old (step-up) | 401 | Bearer error="insufficient_user_authentication", error_description="…", acr_values="…", max_age=… | | Missing role, permission, group, claim, organization, client or caller kind | 403 | none |

When several rules fail at once, a step-up is asked for only if every failing rule is a step-up: a missing scope, role or permission, which no stronger sign-in fixes, answers 403 (with insufficient_scope for a scope) and no step-up.

resource_metadata (RFC 9728 §5.1) is added to every 401 when the verifier publishes the protected resource metadata (resourceMetadata): the verifier's own refusals carry it, and new AuthioForbiddenError(refusal, { resourceMetadata: verifier.resourceMetadataUrl }) (or withResourceMetadata) adds it to a step-up; the guards of @hyper.authio/express do it for you. The table is conformance/challenges.json, which every Authio library runs.

Rules: requireScope, requireRole, requirePermission, requireGroup, requireClaim, requireOrganization, requireAuthenticationLevel, requireAuthenticationMethod, requireRecentAuthentication, requireCallerKind, requireClient, combined with allOf and anyOf. authorize checks every rule and joins their messages.

Step-up (RFC 9470): requireAuthenticationLevel(2, { maxAge: 300 }), requireAuthenticationMethod("otp") and requireRecentAuthentication(300) refuse with insufficient_user_authentication, acrValues (the level asked for; 2 for mfa/otp/mca, 3 for swk/hwk) and maxAge, so the challenge reads Bearer error="insufficient_user_authentication", error_description="…", acr_values="2", max_age=300, answered with 401 (RFC 9470 §3; 403 before 0.8.1). A client answers it with createStepUpAuthorizationUrl from @hyper.authio/client.

Webhooks

import { createAuthioWebhooks } from "@hyper.authio/server";

const webhooks = createAuthioWebhooks({ secret: process.env.AUTHIO_WEBHOOK_SECRET })
  .on("user_created", async (event) => { /* event.data.username */ })
  .on("*", (event) => log(event.type));

const outcome = await webhooks.handle({ body: rawBody, headers: request.headers });
response.statusCode = outcome.status; // 200 handled or duplicate, 400, 401, 413, 500

Deliveries are deduplicated by X-Authio-Event-Id for 24 hours in memory (or a store you pass); a handler that throws releases the id so the redelivery runs. Several secrets are accepted while one is rotated.

Downstream APIs

import { createForwardingFetch, createOnBehalfOf } from "@hyper.authio/server";

const sameAudience = createForwardingFetch(user);  // forwards the caller's own token
const billing = createOnBehalfOf({ client: authioClient, resource: "https://billing.example.com", scopes: "invoices:read" });
await billing.fetch(user)("https://billing.example.com/invoices"); // exchanged once, cached, retried once on 401

License

MIT