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
| Package | Use it for |
|---|---|
| @hyper.authio/express | An 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/server | An 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/client | Workers, 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/testing | Tests: an in-memory realm on loopback that issues tokens, answers introspection and signs webhook deliveries. |
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 # testsPoint 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.
AUTHIO_ISSUER=https://authio.api.hyperio.tech/realms/acme
AUTHIO_CLIENT_ID=orders-worker
AUTHIO_CLIENT_SECRET=YOUR_CLIENT_SECRETProtect 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.
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)| Check | What authioBearer requires |
|---|---|
| Issuer and audience | Exactly 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 lifetime | A 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 type | The typ claim must be Bearer, so ID and refresh tokens are refused. |
| Refusal | 401 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.
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,
)| Guard | Passes when |
|---|---|
requireScope | Any (or, with 'all', every) scope is in scope. |
requireRole | The 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. |
requirePermission | One of the caller’s roles grants the method on the resource, such as orders / write, optionally from given sources. |
requireGroup | A group claim (groups by default) holds the group. |
requireClaim | The claim is present, or holds one of the values you name. |
requireOrganization | org_id is present, or matches the alias or ID you name. |
requireAuthenticationLevel / requireAuthenticationMethod | acr reaches the level, or amr holds the method (mfa, otp, swk…). |
requireClient | azp 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.
| Form | Comes from | Read it with |
|---|---|---|
| Scopes | scope, granted to the client | user.scopes, hasScope() |
| Roles and permissions | user_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 names | Claim 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 claim | Claim mappers (constant, user property) and the person’s own claims, in the access token | user.claims, claimValues(), hasClaim() |
| Organization and client | org_id, org_alias, azp | user.organizationId, user.organizationAlias, user.clientId |
| Sign-in strength | acr, amr, auth_time, idp | user.authenticationLevel, user.authenticationMethods, user.authenticatedAt |
| Delegation | act, delegation_id | user.actor, user.isDelegated, user.delegationId |
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.
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.
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.
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
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
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
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.