BetterIAM
Next.js

Advanced

Step-up, API keys in route handlers, assertions and webhooks for other services, client refresh, the Pages Router, and background delivery in Next.js.

This page covers the parts of @better-iam/next that most applications add after the basics:

Step-up

A valid session only proves that someone signed in, possibly days ago and possibly on a shared computer. Pages that change security settings, show secrets, or lead to irreversible operations should also know that the person is present now and passed a second factor. lets a guard demand that, and send the person to confirm who they are instead of refusing outright.

stepUp: { mfa?: true | 'fresh', maxAgeMs?: number } works on page, route, apiRoute, action, pages.withSession, pages.api, and requireSession. Guards check it after authenticating and before authorizing, with checkStepUp(session, requirement, now). You can also call that function yourself: it returns null when the session qualifies, or { code, reason, message, status: 403 }.

RequirementSatisfied byRefused with
mfa: trueA session that completed MFA, including one a remembered device let through, and impersonated or assumed-role sessions derived from oneMFA_REQUIRED (reason=mfa)
mfa: 'fresh'A user session that verified a factor itselfMFA_REQUIRED, or IMPERSONATION_RESTRICTED for impersonation
maxAgeMsauthenticatedAt within the window (and not in the future)RECENT_AUTH_REQUIRED (reason=recent), or IMPERSONATION_RESTRICTED

mfa: 'fresh' refuses sessions a remembered device satisfied, impersonated sessions, assumed roles, and API keys. Sessions from links.switch do not carry the remembered-device marker, so 'fresh' cannot tell them apart. sessions never satisfy a recency requirement, as on the server. An invalid requirement (an mfa other than true, false, or 'fresh', or a maxAgeMs that is not a positive finite number) throws a TypeError when you create the guard (for requireSession and checkStepUp, when they run).

app/[org]/security/page.tsx
import { iamNext } from '@/lib/iam-next';

// A session older than five minutes, or without MFA, is sent to /reauth?next=...&reason=... first.
export default iamNext.page(
  async (_props: { params: Promise<{ org: string }> }, { session }) => (
    <main>
      <h1>Security settings</h1>
      <p>You signed in at {new Date(session.session.authenticatedAt).toISOString()}.</p>
    </main>
  ),
  { stepUp: { maxAgeMs: 5 * 60_000, mfa: true } },
);

Pages redirect to stepUpPath (or stepUp.redirectTo) with ?next= and ?reason= (mfa, recent, or impersonation). A ReauthenticateForm posting to authActions().reauthenticate there asks for the password and then the second factor when the account has one. That issues a new session with a fresh authenticatedAt, ends the session it replaced, and returns to next.

The server has no in-place upgrade, so a new session is the only way to prove recency. The new cookie stays a browser-session cookie unless the form opts in with keepSignedIn. Route handlers, apiRoute(), and pages.api() answer 403 with the code, and action() returns it as an ActionResult.

Without a step-up page

Without stepUpPath, page guards throw an IamError with the failure's code, status, and reason, whose digest is BETTER_IAM_STEP_UP:<code>:<reason>. Production builds pass only the digest to error.tsx, so match on that prefix there:

app/error.tsx
'use client';

export default function ErrorPage({ error }: { error: Error & { digest?: string } }) {
  if (error.digest?.startsWith('BETTER_IAM_STEP_UP:'))
    return <a href="/reauth">Confirm it’s you to continue</a>;
  return <p>Something went wrong.</p>;
}

maxAgeMs is independent of the server's authentication.recentAuthenticationMs (five minutes by default), which guards the server's own sensitive operations. Keep the two aligned when a page leads to such an operation. The now option of createIamNext replaces the clock for tests. See Sessions for recent authentication on the server.

Service credentials

Scripts, CI jobs, and other services call your routes with API keys, and administrators may act through an . route() accepts only user sessions, so those callers need apiRoute(handler, spec), the variant of route() for machine callers. It authenticates with iam.authenticate, so API keys and assumed-role sessions work as well as browser sessions (cookie or bearer).

app/api/whoami/route.ts
import { iamNext } from '@/lib/iam-next';

export const runtime = 'nodejs';

// Accepts browser sessions, API keys, and assumed roles; the handler sees a sanitized principal.
export const GET = iamNext.apiRoute(
  async (_request, { principal }) => ({
    id: principal.identity.id,
    name: principal.identity.name,
    tenantId: principal.session.tenantId,
    kind: principal.session.kind,
    mfa: principal.session.mfa,
  }),
  { authorize: { action: 'reports:read' } },
);

The handler receives { principal, params }. principal (the ) is an IamPrincipal copied field by field from the stored records, never the records themselves, so token hashes and session policies cannot leak into a response:

PartFields
principal.identityid, tenantId, name, email?, kind? (user or service), status?
principal.sessionid, tenantId (the tenant the credential acts in), kind (user, role, or api-key), mfa, method?, authenticatedAt, expiresAt, roleId?, impersonatorId?, trustedDeviceId?
  • Tenant. authorize defaults to the tenant the credential acts in: the role's tenant for an assumed role, which can differ from the identity's tenant. Its callbacks receive { principal, params, request }.
  • Step-up. API keys never have MFA, and their authenticatedAt is their creation time, so stepUp refuses them for mfa and limits their age for maxAgeMs. Assumed roles inherit both from the session that assumed them and never satisfy mfa: 'fresh'.
  • Everything else (the origin check for cookie mutations, JSON results, the error envelope) works as in route(). apiRoute() needs an instance with authenticate(), which betterIam() provides.

API keys come from credentials.create, usually for a service account, and assumed-role sessions from roles.assume.

Keeping server components in sync

A sign-in or sign-out can happen in the browser: through the typed client, a passkey ceremony, or a session that expired while the tab was open. It changes the cookie, but App Router server components that already rendered keep showing the old identity until the next navigation. IamNextProvider from @better-iam/next/client wraps IamProvider and calls router.refresh() whenever the signed-in identity changes, so layouts and pages re-render with the new cookie.

app/providers.tsx
'use client';
import type { ReactNode } from 'react';
import { createIamClient } from '@better-iam/client';
import { IamNextProvider, useSignOut } from '@better-iam/next/client';
import type { BetterIam } from '@better-iam/server';

const client = createIamClient<BetterIam>({
  baseURL: typeof window === 'undefined' ? 'http://localhost' : window.location.origin,
});
type Session = Awaited<ReturnType<BetterIam['api']['auth']['getSession']>>;

export function Providers(props: { initialSession: Session | null; children: ReactNode }) {
  return (
    <IamNextProvider client={client} initialSession={props.initialSession}>
      {props.children}
    </IamNextProvider>
  );
}

/** Signing out here refreshes the server components through IamNextProvider. */
export function SignOutButton() {
  const signOut = useSignOut({ redirectTo: '/login' });
  return (
    <button type="button" onClick={() => void signOut()}>
      Sign out
    </button>
  );
}
  • Seed it from the server. Pass initialSession={await iamNext.sessionForClient()} from the root layout, so the first render needs no fetch and doesn't trigger a refresh. The refresh fires only when the identity changes after that: sign-in, sign-out, an account switch, or an expired session noticed on focus.
  • useSignOut({ redirectTo }) signs out through the session store, navigates with router.replace when redirectTo is given, and refreshes, in one call.
  • useRouterSync() is the hook inside IamNextProvider, for a provider tree you assemble yourself.
  • Re-exports. The entry re-exports useSession, useAuthorize, useAccessible, useIamClient, and Can from @better-iam/react, along with the auth forms.

Server actions need none of this. Next re-renders the page after an action, and getSession() reads the cookies the action set.

Downstream services and webhooks

Two helpers connect your app with other services in each direction: assertions carry the signed-in person's identity to a service you call, and webhooks tell your app about changes made somewhere else.

Assertions for other services

Your Next.js app often calls other services (billing, reports, search) on the person's behalf. Forwarding the session token would let every service act as that person everywhere, and each would need the IAM database to check it. Instead, the app mints a short-lived signed for one audience, and the service verifies it offline with a key. iamNext.assertion({ tenantId, audience, ttlSeconds?, claims? }) mints one about the current session from a server component or route handler, and returns { token, expiresAt, claims }. The caller needs iam:assertions:create on iam/{audience}, so administrators decide which services each role may call.

app/api/reports/route.ts
export const runtime = 'nodejs';

export const GET = iamNext.route(async (_request, { session }) => {
  const { token } = await iamNext.assertion({ tenantId: session.session.tenantId, audience: 'reports' });
  const response = await fetch('https://reports.internal/summary', {
    headers: { authorization: `Bearer ${token}` },
  });
  return response.json();
});

The receiving service needs only the derived key, iam.assertionKey(), kept in its environment. It verifies tokens offline with withAssertion or verifyAssertionToken from @better-iam/next/edge. Verification runs on Web Crypto, including in edge route handlers and middleware, and follows the server's verifyAssertion rules: HS256 only, the audience, the issuer when given, and the token's lifetime with 30 seconds of clock tolerance.

app/api/summary/route.ts (the downstream service)
import { withAssertion } from '@better-iam/next/edge';

export const runtime = 'edge';
export const GET = withAssertion(
  { key: process.env.IAM_ASSERTION_KEY!, audience: 'reports', authorize: (claims) => claims.mfa },
  async (request, { claims }) => Response.json({ tenant: claims.tid, user: claims.sub }),
);
OptionMeaning
keyiam.assertionKey() (64 hex characters), or the list from iam.assertionKeys() while the secret rotates
audienceThis service's audience; tokens for any other audience are rejected
issuerThe IAM origin (iss), checked when set
toleranceSecondsClock skew allowance, default 30
authorizewithAssertion only: extra checks after verification, such as claims.mfa or a role. Returning false answers 403 ACCESS_DENIED

A missing bearer token answers 401 UNAUTHENTICATED; a bad signature, audience, issuer, or lifetime answers 401 INVALID_ASSERTION (the AssertionError that verifyAssertionToken throws). The claims are iss, sub, aud, iat, exp, jti, tid (the tenant), kind, mfa, method?, impersonatorId?, name, email?, roles, groups, and ext?. Keep the TTL short: the downstream service cannot see a revocation before the assertion expires. Secrets covers rotating the key.

Receiving your own webhooks

Changes also happen outside your pages: an administrator deletes an identity in the console, a SCIM connector deactivates someone, a certification revokes access. tell your application so it can clean up its own data. createWebhookHandler({ secret, onEvent }) is a route handler for your deployment's webhooks that checks the signature and freshness before your code runs:

app/api/webhooks/iam/route.ts
import { createWebhookHandler } from '@better-iam/next/edge';

export const POST = createWebhookHandler({
  // During rotation, list the new and the previous secret.
  secret: [process.env.IAM_WEBHOOK_SECRET!, process.env.IAM_WEBHOOK_SECRET_PREVIOUS!].filter(Boolean),
  onEvent: async (event, delivery) => {
    if (event.type === 'identity:delete') await removeProfile(event.resourceId);
  },
});
  • Signatures. It verifies X-Better-IAM-Signature (v1=<hex HMAC-SHA256> over {timestamp}.{body}) against every configured secret, so you can rotate by listing the new and the previous secret. Unsigned requests, and requests whose signature matches no secret, answer 401 INVALID_SIGNATURE.
  • Limits. It rejects timestamps more than five minutes from the current time (toleranceSeconds, default 300) and bodies over 1 MiB (maxBodyBytes, 413 PAYLOAD_TOO_LARGE) before parsing. Methods other than POST answer 405, and a body that is not a JSON event answers 400 INVALID_INPUT.
  • Retries. It answers 500 HANDLER_FAILED when onEvent throws, and Better IAM retries with exponential backoff. Deliveries can repeat after a timeout, so make onEvent idempotent on event.id (or delivery.deliveryId).
  • Ordering. Events carry sequence and hash from the , so a consumer can detect gaps. delivery holds deliveryId, webhookId, event, and timestamp from the delivery headers.

verifyWebhook({ secret, timestamp, body, signature, toleranceSeconds?, now? }) is the same check as the server's verifyWebhookSignature, on Web Crypto, for your own handlers. See Webhooks for subscriptions and Audit chain for sequence and hash.

Background work

Better IAM does not send email, SMS, or webhooks inside the request that caused them. It writes them to an in the same transaction, and events go to a dispatcher, so a slow mail provider never slows down or breaks a sign-in. Something then has to run the outbox and the dispatcher. On a long-lived Node server a setInterval is enough. On Vercel and other serverless hosts there is no process left running between requests, so turn on dispatchAfterResponse:

lib/iam-next.ts
// Email, SMS, webhooks, and events go out after each response (Next's after()).
export const iamNext = createIamNext(iam, { dispatchAfterResponse: true });

Every in-process client() and pages.client() call, and every POST to the handlers() or pages.handler() mount, then schedules one dispatch that runs once the response has been sent. A sign-in code or reset email leaves within the same invocation, with no separate worker.

  • Concurrency. Runs are single-flight per instance, with the outbox and events on separate lanes, so a slow event handler cannot hold up email. A call made during a run waits for it and triggers exactly one more. An outbox run older than the server's 60-second claim lease no longer holds back new ones.
  • Fallback. Where after() cannot run (the Pages Router, or outside a request scope), the run starts immediately without being awaited.
  • Re-entrancy. Inside an event handler or delivery callback, call background.schedule() rather than awaiting dispatch(). Where AsyncLocalStorage is available (Next provides it on Node and edge), awaiting dispatch() there resolves at once with { deferred: true } and queues the follow-up run.

iamNext.background also has dispatch(), which runs a pass now and resolves with { outbox?: { delivered, failed, abandoned, rounds }, events?: { dispatched } }. Its schedule() queues a pass after the response and never throws. The background option of createIamNext takes:

  • onError(error, task): receives failures nobody awaits (default console.error).
  • after: an after() override.
  • outboxLimit: messages per round, 1 to 1000 (default 100).
  • maxRounds: rounds per pass (default 10); another round runs only while the last one was full.

Scheduled jobs

background.cron(options) covers everything interactive traffic does not trigger: retries with backoff, quiet periods, and the periodic jobs.

app/api/cron/route.ts
import { iamNext } from '@/lib/iam-next';

export const runtime = 'nodejs';
export const GET = iamNext.background.cron({
  secret: process.env.CRON_SECRET,
  tasks: {
    purge: { retentionMs: 30 * 86_400_000 },
    auditRetention: { retentionMs: 365 * 86_400_000 },
    digest: true,
    reminders: true,
    outbox: true,
    events: true,
  },
});

On Vercel, schedule it in vercel.json:

vercel.json
{ "crons": [{ "path": "/api/cron", "schedule": "*/10 * * * *" }] }
TaskRunsDefault
outboxDelivers queued outbox messages, repeated while batches come back fullon
eventsDispatches pending events to plugins, events.onEvent, and subscriberson
purgepurgeDeleted: expires lapsed access and removes deleted tenants past retention ({ retentionMs? })off
auditRetentionpruneAudit for every tenant that is not deleted, or the tenants you list ({ retentionMs, tenants? })off
digestsendAccessDigest with the options you passoff
reminderssendExpiryReminders with the options you passoff
  • Authentication. The route requires Authorization: Bearer <secret> (Vercel Cron sends CRON_SECRET this way; secret defaults to process.env.CRON_SECRET). It compares in constant time and refuses to run anything when no secret is configured (500 CRON_NOT_CONFIGURED).
  • Order. Jobs run first, then the outbox drain and events, so the email a job queues leaves in the same run.
  • Results. Each task is isolated. The response lists per-task results and errors, and is 500 if any task failed. Unexpected failures read TASK_FAILED without internal detail and go to background.onError. eventsWaitMs (default 10 000) bounds how long the events task waits for a dispatch already running in the process before failing with BUSY.

Jobs covers the same jobs for other hosts.

Pages Router

Applications that still use pages/ (or mix both routers) get the same guarantees through iamNext.pages. The Pages Router hands you Node req/res objects and getServerSideProps instead of server components, so these helpers take those shapes:

HelperUse
pages.handler()pages/api/iam/[...path].ts. It re-encodes bodies Next already parsed, so bodyParser can stay on; turn it off only for byte-exact protocol callbacks
pages.withSession(gssp, spec)getServerSideProps: a login redirect with ?next=, notFound (or authorize.redirectTo) on denial, and the session in props
pages.api(handler, spec)API routes: session cookie or bearer session token, stepUp, authorize, the JSON error envelope, JSON results, and 204 for undefined
pages.client(req, res)The in-process typed client. Issued cookies are appended to res, so API routes can sign people in and out
pages.getSession(req)The session for any Node request with headers, or null
pages/api/iam/[...path].ts
import { iamNext } from '@/lib/iam-next';

export default iamNext.pages.handler();
  • withSession redirects signed-out visitors to the login path with ?next= (the resolved URL) and sessions that must step up to the step-up page. It serializes the session through JSON, without its token hash, before adding it to the props, because Next requires serializable props. Its authorize callbacks receive { session, params, query }, and gssp is optional.
  • pages.api applies the same origin check to cookie mutations as route(). Results are sent as JSON unless the handler already responded.
  • pages.handler() accepts parsed JSON and form bodies and streams unparsed ones, up to 2 MiB.

Runtime notes

These rules apply to every @better-iam/next helper, on this page and the others:

  • Route handlers that touch iam need export const runtime = 'nodejs'. The edge helpers run anywhere.
  • iam.endpoint gives client() the origin and base path. A custom IamLike without it can pass baseURL and basePath options; otherwise the origin is derived from x-forwarded-host / host, and it must be one of the server's trusted origins.
  • The helpers never cache across requests. Memoization is scoped to one render, and there is no module-level session state.
  • Only IamError and IamClientError become responses or ActionResults. Other errors and Next control flow (redirect(), notFound(), forbidden(), unauthorized()) propagate unchanged in every wrapper; isNextControlError(error) and isAuthenticationError(error) are exported for your own wrappers.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page