Skip to content

Node.js

Protect Node.js APIs, call APIs from workers with client credentials and receive webhooks with the Authio Node.js packages.

The Authio Node.js SDK is four packages on npm, in the @hyper.authio organization, version 0.1.1, for Node.js 20.19 or later, as ES modules and CommonJS. They read every endpoint from your realm’s discovery document and already understand Authio’s claims: scopes, the roles and permissions of each *_resource_access claim, groups, organizations and sign-in strength.

Pick a package

PackageUse it for
@hyper.authio/expressAn Express 4 or 5 API: bearer validation, guards for scopes, roles, permissions, groups, claims, organization and sign-in strength, and a signed webhook route.
@hyper.authio/serverAn API on any other framework (Fastify, Hono, Nest, plain http): the same validation and rules as functions, plus webhooks, token forwarding and on-behalf-of.
@hyper.authio/clientWorkers, CLIs and calls to Authio from anywhere, edge runtimes included: discovery, every token grant, introspection, revocation, UserInfo, a cached client-credentials fetch and webhook verification.
@hyper.authio/testingTests: an in-memory realm on loopback that issues tokens, answers introspection and signs webhook deliveries.
TerminalBash
npm install @hyper.authio/express        # an Express API (brings server and client)
npm install @hyper.authio/server         # an API on another framework
npm install @hyper.authio/client         # a worker, a CLI, or calls to Authio
npm install -D @hyper.authio/testing     # tests
Next.js apps sign in with @hyper.authio/provider-nextauth, and MCP servers use @hyper.authio/mcp. Both are in the same organization.

Point at your realm

Every package takes the realm’s issuer, {authority}/realms/{realm}, either as issuer or as authority plus realm. The discovery document is read from it, cached for an hour and refused if it names a different issuer; an http address is refused unless it is loopback. A client also takes its clientId and how it authenticates: client_secret_basic (the default), client_secret_post, private_key_jwt or none for a public client.

.envBash
AUTHIO_ISSUER=https://authio.api.hyperio.tech/realms/acme
AUTHIO_CLIENT_ID=orders-worker
AUTHIO_CLIENT_SECRET=YOUR_CLIENT_SECRET

Protect an API

Register your API as a resource server, say https://api.example.com with the scopes orders:read and orders:write, and accept only its identifier as the audience. Clients ask for resource=https://api.example.com and their tokens carry it as aud.

server.tsTypeScript
import express from 'express'
import { authioBearer, requirePermission, requireScope } from '@hyper.authio/express'

const app = express()

app.use(
  '/api',
  authioBearer({
    issuer: process.env.AUTHIO_ISSUER!,
    audiences: 'https://api.example.com',
  }),
)

app.get('/api/me', (req, res) => {
  res.json(req.authio!.user)
})

app.get('/api/orders', requireScope('orders:read'), requirePermission('orders', 'read'), (req, res) => {
  res.json([])
})

app.listen(3001)
CheckWhat authioBearer requires
Issuer and audienceExactly the realm’s issuer, and one of audiences. A token addressed elsewhere is refused with an error_description that names the audience the API accepts.
Signature and lifetimeA key from the realm’s JWKS with an asymmetric algorithm (RS, PS, ES), re-read when a token names an unknown kid; exp and nbf with 30 seconds of clockSkew.
Token typeThe typ claim must be Bearer, so ID and refresh tokens are refused.
Refusal401 with WWW-Authenticate: Bearer when there is no token, and Bearer error="invalid_token" with the reason when it is not valid. problemDetails: true adds an RFC 9457 body.

Require scopes, roles, permissions and groups

Every guard is Express middleware and also a rule, so requireAuthio, allOf and anyOf combine them.

routes.tsTypeScript
import {
  anyOf,
  requireAuthenticationLevel,
  requireAuthio,
  requireClaim,
  requireGroup,
  requireOrganization,
  requirePermission,
  requireRole,
  requireScope,
} from '@hyper.authio/express'

// Any of the scopes; pass 'all' to need every one.
app.get('/api/orders/export', requireScope(['orders:read', 'orders:export'], 'all'), exportOrders)

// A role assigned to the person or one of their groups, not to everyone in the realm.
app.delete('/api/orders/:id', requireRole('orders-admin', { sources: ['user'] }), deleteOrder)

// A group (Group claim mapper) and a claim (any mapper, or the person's own claim).
app.get('/api/finance', requireGroup('finance'), requireClaim('department', 'finance'), report)

// A permission, and either a strong sign-in or the acme organization.
app.post(
  '/api/payouts',
  requireAuthio(requirePermission('payouts', 'create'), anyOf(requireAuthenticationLevel(2), requireOrganization('acme'))),
  payout,
)
GuardPasses when
requireScopeAny (or, with 'all', every) scope is in scope.
requireRoleThe caller holds the role, case-insensitive. With sources, only roles from those claims count: user (the person and their groups), realm, client. Needs the roles scope.
requirePermissionOne of the caller’s roles grants the method on the resource, such as orders / write, optionally from given sources.
requireGroupA group claim (groups by default) holds the group.
requireClaimThe claim is present, or holds one of the values you name.
requireOrganizationorg_id is present, or matches the alias or ID you name.
requireAuthenticationLevel / requireAuthenticationMethodacr reaches the level, or amr holds the method (mfa, otp, swk…).
requireClientazp is one of the clients — useful to admit one machine client.

A missing scope answers 403 with WWW-Authenticate: Bearer error="insufficient_scope" and the scopes needed; a sign-in that is not strong enough answers insufficient_user_authentication, so the client can send the person through a stronger sign-in. Any other refusal is a plain 403.

Read the caller

req.authio.user is an AuthioUser, read the same way whether the token was validated locally or by introspection. It holds every form of permission Authio issues, and the raw claims for anything else. The access token itself is kept out of JSON and logs.

FormComes fromRead it with
Scopesscope, granted to the clientuser.scopes, hasScope()
Roles and permissionsuser_resource_access (the person, directly or through a group), realm_resource_access, client_resource_access, consumer_resource_access and secret_resource_access for machine credentials, each { role: { resource: [method] } }user.roles, user.permissions, hasRole(), hasPermission(), user.resourceAccess per source
Groups and flat role namesClaim mappers with the Group or Role source, under the name you gave them (groups and role by default; change with groupClaims and roleClaims)user.groups, hasGroup(), user.roles
Any other claimClaim mappers (constant, user property) and the person’s own claims, in the access tokenuser.claims, claimValues(), hasClaim()
Organization and clientorg_id, org_alias, azpuser.organizationId, user.organizationAlias, user.clientId
Sign-in strengthacr, amr, auth_time, idpuser.authenticationLevel, user.authenticationMethods, user.authenticatedAt
Delegationact, delegation_iduser.actor, user.isDelegated, user.delegationId
Check permissions rather than role names where you can: a role can be renamed in the console, the permission it grants is what the code depends on.

Other frameworks

@hyper.authio/server holds the same validation and rules without Express. A refusal is an AuthioBearerError (401) or an AuthioForbiddenError (403), each with the WWW-Authenticate value to send.

auth.tsTypeScript
import { AuthioForbiddenError, authorize, createAuthioVerifier, requirePermission } from '@hyper.authio/server'

const verifier = createAuthioVerifier({
  issuer: process.env.AUTHIO_ISSUER!,
  audiences: 'https://api.example.com',
})

export async function caller(authorization: string | undefined) {
  // Throws AuthioBearerError: answer 401 with error.wwwAuthenticate.
  const user = await verifier.verifyAuthorizationHeader(authorization)
  const refusal = authorize(user, requirePermission('orders', 'read'))

  // AuthioForbiddenError: answer 403, with error.wwwAuthenticate when it has one.
  if (refusal) throw new AuthioForbiddenError(refusal)

  return user
}

Notice revocations before the token expires

A locally validated token stays valid until it expires, even after its session ended or its user was suspended. introspection: 'activeCheck' also asks Authio whether the token is still active, cached per token for 30 seconds; 'introspectionOnly' validates by introspection alone, for reference or encrypted tokens. Both need the API’s confidential client, the one the resource server names as its introspection client: Authio reports a token addressed to a resource server as active only to that client.

server.tsTypeScript
app.use(
  '/api',
  authioBearer({
    issuer: process.env.AUTHIO_ISSUER!,
    audiences: 'https://api.example.com',
    introspection: 'activeCheck',
    client: { clientId: process.env.AUTHIO_CLIENT_ID!, clientSecret: process.env.AUTHIO_CLIENT_SECRET },
  }),
)

Call an API from a worker

clientCredentialsFetch is a fetch that sends a client-credentials token for the scopes and resource you name. The token is cached and renewed a minute before it expires, with one request for concurrent callers; on a 401 it is dropped and the call retried once.

worker.tsTypeScript
import { createAuthioClient } from '@hyper.authio/client'

const authio = createAuthioClient({
  issuer: process.env.AUTHIO_ISSUER!,
  clientId: process.env.AUTHIO_CLIENT_ID!,
  clientSecret: process.env.AUTHIO_CLIENT_SECRET,
})

const ordersFetch = authio.clientCredentialsFetch({
  scopes: 'orders:read',
  resource: 'https://api.example.com',
})

const response = await ordersFetch('https://api.example.com/api/orders')

To keep the secret off the wire, register the client’s public key and set clientAuthentication: 'private_key_jwt' with clientPrivateKey (PEM or JWK) or clientPrivateKeyPath, and clientKeyId: the client signs a short-lived assertion for every request. The same client covers the other grants: authio.tokens.authorizationCode, refresh (store the rotated refresh token it returns), exchange for on-behalf-of, deviceAuthorization and pollDevice for the device flow, plus introspect, revoke and userInfo.

Call another API on behalf of the caller

billing.tsTypeScript
import { createAuthioClient } from '@hyper.authio/client'
import { createOnBehalfOf } from '@hyper.authio/server'

const billing = createOnBehalfOf({
  client: createAuthioClient({ issuer, clientId, clientSecret }),
  resource: 'https://billing.example.com',
  scopes: 'invoices:read',
})

app.get('/api/invoices', async (req, res) => {
  const response = await billing.fetch(req.authio!.user)('https://billing.example.com/invoices')
  res.status(response.status).json(await response.json())
})

The caller’s token is exchanged (RFC 8693) once per token, resource and scopes and cached; the new token keeps the person as sub and names your API in act. To pass the caller’s token on unchanged to an API that accepts the same audience, use createForwardingFetch(user).

Receive webhooks

server.tsTypeScript
import { authioWebhooks } from '@hyper.authio/express'

// Before express.json(): the signature is checked over the raw body.
app.post(
  '/webhooks/authio',
  authioWebhooks({ secret: process.env.AUTHIO_WEBHOOK_SECRET })
    .on('user_deleted', async (event) => forget(event.data.id))
    .on('*', (event) => console.log(event.type)),
)

The route verifies X-Authio-Signature against every configured secret (pass secrets while you rotate one), refuses a signature older than five minutes, and acknowledges a redelivery of the same X-Authio-Event-Id without running the handlers again. It answers 200 handled or duplicate, 400 unreadable, 401 bad signature, 413 too large and 500 when a handler throws, which lets Authio deliver again. See Webhooks for the events.

Test without a realm

orders.test.tsTypeScript
import { FakeAuthio } from '@hyper.authio/testing'

const authio = await FakeAuthio.start({ realm: 'acme' })

const token = await authio.issueAccessToken({
  sub: 'u-1',
  audience: 'https://api.example.com',
  scope: 'orders:read',
  claims: { user_resource_access: { clerk: { orders: ['read'] } }, groups: ['finance'] },
})

// Point the API at authio.issuer, call it with the token, then:
await authio.close()

The fake serves discovery, JWKS, every grant, introspection, revocation and UserInfo on loopback, records each request, and can answer with scripted errors. createWebhookDelivery builds a signed delivery for the webhook route.

Analytics cookies

We would like to measure how the console is used, with Google Analytics, so we can see which screens people get stuck on. It writes a cookie that recognises this browser on your next visit.

Refusing changes nothing else. The cookies that keep you signed in and remember your language and theme are needed to run the console, and stay either way. Read the Privacy Policy