JavaScript apps
@masks/server signs people in to a JavaScript server app against a masks server. It covers what
Masks::Rails covers in a Rails app: the handshake, the sign-in, a
session, organizations, privileges, and checking access tokens. Its core uses only fetch,
Request, Response, and Web Crypto, so it runs on Node 20 and later, Bun, and Deno.
npm install @masks/serverEach framework has its own entry point and page. The framework is an optional peer dependency, so install only the one your app uses.
| Page | Import | Peer dependency |
|---|---|---|
| Next.js | masksNext from @masks/server/next |
next 15 or later |
| Auth.js | masksAuth from @masks/server/authjs |
next-auth 5 |
| Express | masksExpress from @masks/server/express |
express 4.18 or later |
| Hono | masksHono from @masks/server/hono |
hono 4 or later |
A site with no server uses @masks/client instead. See static sites.
The @masks/server reference lists every option, route, and
method.
Setting up
Section titled “Setting up”Every integration except Auth.js reads three settings. Each is an option, or an environment variable:
| Option | Variable | |
|---|---|---|
issuer |
MASKS_ISSUER |
The tenant you sign in against, such as https://acme.auth.example.com. |
resource |
MASKS_RESOURCE |
Your app’s absolute URL, such as https://app.example.com. The handshake registers the app for it, and tokens for the app name it as their audience. |
secret |
MASKS_SECRET |
At least 32 characters that seal the session cookie, such as the output of openssl rand -base64 32. |
The app builds its own addresses, such as its redirect URI, from resource, so they stay correct
behind a proxy. An http issuer works only on localhost, a .localhost name, or a loopback
address, unless allowPlainHTTP is set.
issuer, resource, name, and organization each accept a function that takes the request, so
one app can serve several tenants. Derive them from something the app trusts, such as a tenant it
looked up by host.
Any other framework
Section titled “Any other framework”Masks from @masks/server works with any framework that hands you a Request:
masks.handle(request)answers a masks route, and returnsundefinedfor every other path.masks.require,masks.protect, andmasks.guardcheck a request. SeeMasks.
A cookies array holds Set-Cookie values. Append each one to the response you send.
The handshake
Section titled “The handshake”The handshake connects your app to the masks server:
- The app sends the browser to the masks server’s approval screen.
- You approve the app there.
- A one-time token comes back. The app redeems it by talking to the masks server directly, so the client secret never passes through the browser.
The handshake registers the app for resource. It registers these addresses under /auth, or
basePath:
/auth/callback |
The redirect URI for a sign-in. |
/auth/handshake/callback |
Where masks sends the browser after the approval. |
/auth/logout/return |
The only address masks sends the browser back to after a sign-out. |
/auth/logout/backchannel |
Where masks posts a logout token. It is registered only when masks supports back-channel logout. |
It asks for scope and the scopes of every privilege. Set namespace, such as
catalog:, to register the namespace once for every scope under it.
Credentials
Section titled “Credentials”By default, the app writes its credentials to masks.json in the working directory, with file
mode 600. Add it to .gitignore. When MASKS_CLIENT_ID is set, the app reads MASKS_CLIENT_ID
and MASKS_CLIENT_SECRET instead and writes nothing.
A host with no writable disk, or a multi-tenant app, sets credentials to a store of its own:
import type { CredentialStore } from "@masks/server";
const credentials: CredentialStore = { read: (request) => tenantFor(request).masksCredentials(), write: (request, held) => tenantFor(request).connect(held), clear: (request) => tenantFor(request).disconnect(),};Before a sign-in starts, the app checks that masks still knows it, at most once a minute. When masks has forgotten the app, the app forgets its credentials and starts the handshake again.
Managing the connection
Section titled “Managing the connection”Once the app is connected, /auth/handshake shows the connection to somebody manages allows, with
buttons to reconnect and to disconnect. manages is the name of a privilege, or a function:
masksNext({ manages: "admin" });masksNext({ manages: (request, masked) => masked.subject === process.env.OWNER_SUB,});- A person who is not signed in goes to sign in, and comes back to
/auth/handshake. - When
managesnames a privilege whose scopes the person lacks, masks is asked for them once, as on any guarded page. - Anybody else is refused with
403. - When
managesis not set, everyone is refused.
Disconnecting deletes the registration at masks and the credentials. When masks cannot be reached
to delete the registration, the app keeps the credentials and answers 503, so masks keeps no
registration that the app no longer knows.
masks.connection, masks.registered, masks.disconnect, and masks.client read and change the
connection from code. See Masks.
Signing in
Section titled “Signing in”The app answers its routes under /auth, or basePath. A
sign-in starts at /auth, which takes return_to, organization, and privilege.
- A sign-in uses PKCE, a
state, and anonce. - The app checks the
issthat masks sends back, and the ID token’s signature, audience, and nonce. - A callback the browser did not start sends the browser to sign in again, at most once a minute.
In between, it is refused with
invalid_state. return_tois followed only to a path on the app.
A request that wants JSON gets 401 instead of a redirect, with login_required and a login_url,
or handshake_required and a handshake_url. A request wants JSON when its Accept header does not
name text/html, or when it sends X-Requested-With: XMLHttpRequest.
The person is a Masked, and never undefined. toJSON()
leaves out the access token, so passing a Masked to Response.json or res.json never sends the
token to the browser.
masks.loginURL({ returnTo: "/billing" }) builds a link to sign in. masks.logoutPath is the path
a sign-out form posts to:
<form method="post" action="/auth/logout"> <button type="submit">Sign out</button></form>Refreshing
Section titled “Refreshing”A token that expires within 30 seconds is refreshed with its refresh token. The app reads the person’s name, photo, and organizations from userinfo again at the same time.
- Requests that arrive together with the same expired token share one refresh, since masks revokes a refresh token that is used twice.
- A refresh that masks refuses signs the person out.
- A refresh that does not reach masks keeps the session, and the next request tries again.
Signing out
Section titled “Signing out”A sign-out revokes the refresh token and clears the session. A sign-out of any method is refused
with 403 when its Sec-Fetch-Site header is anything other than same-origin or none, or its
Origin header names another origin. A POST or DELETE that sends neither header is refused with
403 as well.
A GET sign-out signs out at once when its Sec-Fetch-Site header is same-origin or none. A
GET with no Sec-Fetch-Site header, as older browsers send, does not sign out. A browser gets a
page with a Sign out button that posts to the same address, query included. A JSON request gets
403. So a link on another site cannot sign anybody out.
With signOutOfIssuer, or everywhere, the browser goes on to masks to end its session there. It
comes back through /auth/logout/return to afterSignOut. That return carries a state the sign-out
stored in the browser, and the state works once, so a link to the return from anywhere else is
refused.
When masks ends a session itself, it sends a logout token to /auth/logout/backchannel. The app signs
out the session the token names, or every session of the person it names, at their next request.
- Each logout token is accepted once, and only before its
exp. A token with noexpis refused. - When the store cannot be reached, the app answers
503, and accepts the same token when masks sends it again. onLogoutis called with each logout token.
Sessions
Section titled “Sessions”The session lives in a cookie, sealed with AES-GCM under a key derived from secret.
- It is
HttpOnlyandSameSite=Lax. Onhttps, it is alsoSecureand named__Host-masks, so no other host can set it. - A session too large for one cookie is split across up to eight.
- It lasts 14 days from the sign-in, or
maxAgeseconds. - The browser cannot read the tokens in it, and
/auth/sessionleaves them out. - It is replaced at every sign-in.
- It records the issuer that signed the person in, and the app ignores it under any other issuer. An app that serves several tenants from one origin keeps their sessions apart.
To keep sessions on the server, set sessions to a Store.
The cookie then holds only the session’s id, sealed. The store holds the session, also sealed, under
a key that names no token. A sign-in gives the session a new id and deletes the old one.
To change the secret, list the new one first: secret: [next, previous], or
MASKS_SECRET=next,previous. Sessions sealed with the old secret still open, and each is sealed with
the new one the next time it changes.
Running several processes
Section titled “Running several processes”store holds what the app’s processes share. It is in memory by default, so an app that runs as
several processes, or on serverless functions, sets it to a
Store they all reach, such as Redis. It holds:
- Each refresh, so a session is refreshed once between the processes. A refusal is kept too, so every process signs the person out.
- The back-channel logouts masks sent, so every process ends those sessions.
- The logout tokens and DPoP proofs already used, so none is accepted twice.
When the store cannot be reached, a refresh keeps the current token and runs again on a later request.
Organizations
Section titled “Organizations”An app signs a person in as a member of one organization when it asks for
the organization scope. Name one for every sign-in with organization, or for one sign-in with
/auth?organization=acme:
masksNext({ scope: ["openid", "profile", "email", "offline_access", "organization"], organization: (request) => new URL(request.url).hostname.split(".")[0],});masked.organization |
The organization signed in to. |
masked.organizations |
Every organization the person has joined. |
masked.role("owner") |
Whether the person holds that role in the organization signed in to. |
/auth/session returns organization and organizations as well. The app reads the organizations
from userinfo at sign-in and at every refresh, so a changed role reaches the app within an access
token’s lifetime.
To let a person switch, link each organization to a new sign-in. masks asks a person who already belongs to it for nothing more:
{ masked.organizations.map((held) => ( <a key={held.id} href={masks.loginURL({ organization: held.key, returnTo: "/" })} > {held.name} </a> ));}Privileges
Section titled “Privileges”A privilege is a name for what a route needs. It can ask for scopes the person holds in masks, a role in the organization they signed in to, or both:
masksNext({ scope: [ "openid", "profile", "email", "offline_access", "organization", "catalog:read", ], privileges: { admin: ["catalog:admin"], billing: { role: ["owner", "billing"] }, acme_admin: { scope: ["catalog:admin"], role: "owner", organization: "acme", }, },});- A list is a short way to write
{ scope: [...] }. roletakes one role or several. Any one of them is enough.organizationlimits the privilege to one organization, named by key or id.
| The sign-in holds the privilege | The route runs. |
| A scope is missing | The browser goes through masks once. It asks for the usual scopes and the privilege’s scopes, then comes back to the page it was on. A JSON request gets 403 with privilege_required, the privileges, and the login_url to send the browser to. |
| masks did not grant the scope on that trip | 403 insufficient_scope. The person does not hold the scope, so the app does not send them through masks again for five minutes. |
| A role or organization does not match | 403 insufficient_role or insufficient_organization. The browser does not go through masks. |
An ordinary sign-in never asks for privilege scopes. masks puts a scope in the token only for a person who already holds it, so asking never gives anyone more access. The handshake registers the scopes of every privilege, so the issuer lets the app ask for them.
require, protect, guard, requireAuth, withMasks, and loginURL throw on a name that
privileges does not define. In a URL, the app ignores the name.
Accepting tokens
Section titled “Accepting tokens”A resource server is an app that accepts access tokens from other apps. resource is the audience
that tokens must name, and resourceScopes lists the scopes your app accepts. In Next.js,
withMasks checks a token when the request carries one:
import { masksNext } from "@masks/server/next";
export const { handlers, withMasks } = masksNext({ resourceScopes: ["catalog:read"], privileges: { read: ["catalog:read"] },});import { withMasks } from "@/lib/masks";
export const GET = withMasks( async (request, { masked }) => Response.json({ subject: masked.subject }), "read",);import { handlers } from "@/lib/masks";
export const { GET } = handlers;In Express and Hono, requireAuth("read") checks a token when the request carries one, and the
middleware serves /.well-known/oauth-protected-resource itself.
The check takes the same privileges, roles and organizations included, and checks them against the token:
- A missing or invalid token is refused with
401. The challenge for a missing token carries no error code, as RFC 6750 asks. The one for an invalid token carriesinvalid_token. - A token that lacks a privilege is refused with
403andinsufficient_scope,insufficient_role, orinsufficient_organization. A token cannot go through masks again, so there is no second trip. - A refusal carries an RFC 6750 challenge whose
resource_metadatapoints at/.well-known/oauth-protected-resource. - When the app cannot check a token, because the issuer’s keys could not be fetched or the store
failed, it answers
503with no challenge, so the client keeps a token that may be valid.
The app fetches the issuer’s keys again every five minutes. While masks cannot be reached, keys fetched within the last hour still check tokens.
A token bound to a key with DPoP is accepted only as
Authorization: DPoP, with a proof that:
- is signed by that key
- names this request’s method, URL, and token
- was made within the last 60 seconds, with 30 seconds of leeway for the clock
- has not been seen before
The URL is the resource’s origin and the request’s path, so a proxy in front of the app needs no other setting.
An API with no browser sign-in uses Verifier on its own.
Delegations
Section titled “Delegations”A delegation lets the app act as a person at a provider
while they are away. Set delegations to the path the provider’s answer comes back to. The handshake
then asks for masks:delegate: and registers that path:
export const { masks } = masksNext({ delegations: "/connect/callback" });With Auth.js, pass the same option to masksAuth, and use the masks it returns the same way:
export const { handlers, auth, masks } = masksAuth(NextAuth, { delegations: "/connect/callback",});export const GET = (request: Request) => masks.startDelegation(request, "google");export async function GET(request: Request) { const { connection, cookies } = await masks.finishDelegation(request);
await save(connection.connection, connection.secret);
const headers = new Headers({ location: "/settings" }); for (const cookie of cookies) headers.append("set-cookie", cookie);
return new Response(null, { status: 303, headers });}startDelegation sends a person who is not signed in to sign in first. finishDelegation refuses a
connection that this browser did not start, that somebody other than the person signed in started,
or that masks made for another account.
Keep the secret encrypted. To call the provider later, ask for its token:
const delegations = await masks.delegations(request);const upstream = await delegations.token(secret, connection);
await save(connection, upstream.secret);Save upstream.secret before anything else, since each call replaces the secret. A
DelegationRefused or DelegationUnavailable carries the replacement in secret even when the
call fails after the refresh.
DelegationUnavailablemeans masks or the provider did not answer, and nothing was lost.DelegationRefusedmeans masks refused the call. ItssignedInAgainistruewhen the person has to sign in again.
Machines and enrollment
Section titled “Machines and enrollment”A JavaScript program on a machine, such as an agent an app installs, signs in as itself with
Client.
An app that has claimed a namespace, through namespace in its
handshake, enrolls each machine without a browser. It asks masks for an initial access token and
hands it to the machine:
const client = await masks.client(request);const enrollment = await client.enroll({ scope: ["catalog:agent"], name: "nas",});The machine makes its own keys and registers with the token:
import { Issuer, Key } from "@masks/server";
const key = await Key.generate();const issuer = await Issuer.discover("https://acme.auth.example.com");const registration = await issuer.register( { client_name: "nas", token_endpoint_auth_method: "private_key_jwt", jwks: key.jwks(), }, enrollment.initial_access_token,);client.unenroll(machineClientId) archives the machine and revokes its tokens. See
enrolling a machine.