Skip to content
anchr.ing
Guide

Protect your API

Verify access tokens offline in Hono, Express or any other framework, read the user, add policies and permissions, accept personal access tokens.

Register a resource app with an audience (e.g. acme-api) and give the web and CLI apps the same audience, so the tokens they get carry it in aud. Verification is offline against cached keys: no call to anchring per request.

Hono

api/server.ts
import { Hono } from 'hono';
import { createAuth } from '@anchring/auth/server';
import '@anchring/auth/hono'; // types c.var.user / c.var.resource

const auth = createAuth({ project: 'acme', env: process.env.AUTH_ENV ?? 'test', audience: 'acme-api' });
const app = new Hono();

app.use('/api/*', auth.middleware());   // 401 without a valid token
app.get('/api/reports', (c) => c.json(listReports(c.var.user.sub)));

The @anchring/auth/hono import types c.var.user; if your app already types its own user, use new Hono<AuthEnv>() instead. c.var.user has sub, type (session, apiKey or impersonation), scopes, amr, env and all claims.

Express

api/server.ts
import express from 'express';
import { createAuth } from '@anchring/auth/server';
import { authMiddleware } from '@anchring/auth/express';

const auth = createAuth({ project: 'acme', env: 'test', audience: 'acme-api' });
const app = express();

app.use(express.json());
app.use('/api', authMiddleware(auth));
app.get('/api/me', (req, res) => res.json(req.user));  // also res.locals.user

The caller IP defaults to req.ip, so set Express trust proxy when you use allowIps behind a proxy.

Anything else

const result = await auth.authenticate(request.headers.get('authorization'), { audience: 'acme-api' });
if (!result.ok) return new Response(JSON.stringify(result.body), { status: result.status, headers: result.headers });
const user = result.user; // AuthUser: sub, type, scopes, amr, env, claims …

// or, for a bare token:
const verified = await auth.verify(token, { audience: 'acme-api' }); // throws AuthError

authenticate never throws for bad input: it returns the response to send, with the right WWW-Authenticate header.

Policies

auth.policy({ maxSessionAge: '12h', requireMfaWithin: '1h', allowIps: ['10.0.0.0/8'] });
app.use('/api/billing/*', auth.middleware({ policy: { requireMfaWithin: '15m' } })); // stricter per route

A session too old or without a recent second factor gets the RFC 9470 step-up challenge (401 insufficient_user_authentication); the browser app answers it with signIn({ prompt: 'login', params: { acr_values: 'mfa' } }) and retries.

Permissions

Teams and roles stay in your own database, keyed by sub. One typed definition covers roles, resource rules and token ceilings, evaluated locally:

const perms = auth.permissions({
  roles: {
    viewer: ['report:read'],
    member: ['report:read', 'report:write'],
    admin: ['report:*', 'members:manage'],
  },
  resources: { report: { 'report:write': ({ user, resource }) => resource.ownerId === user.sub } },
  tokens: { apiKey: { max: ['report:read'] } },          // CI keys never exceed this
  context: async (user) => ({ role: await roleOf(user.sub) }),
});

app.put('/api/reports/:id', perms.guard('report:write', (c) => loadReport(c.req.param('id'))), handler);

Personal access tokens

Users create personal access tokens on their account page for scripts and CI. Accept them in the middleware with the resource app's audience; the middleware exchanges each one for a short-lived token and caches it:

const auth = createAuth({ project: 'acme', env: 'prod', audience: 'acme-api', pat: { audience: 'acme-api' } });
app.use('/api/*', auth.middleware()); // accepts JWTs and kh_pat_… alike

Every option, error and token type: the SDK README.