npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@absolutejs/auth

v0.96.0

Published

An authorization library for absolutejs

Downloads

9,355

Readme

Absolute Auth

Server applications should import the primary authentication contract from @absolutejs/auth/server. This declaration-stable entry point exposes auth, session types, route protection, provider configuration, and the other core server utilities without loading declarations for every optional Auth feature. OIDC provider integrations should likewise import signing keys, token verification, provider stores, and provider types from @absolutejs/auth/oidc. The root entry point remains available for applications that need the complete feature export surface. auth() exposes the complete reusable request context (protectRoute, requireRecentAuth, optional protectPermission, and protectAgent) while keeping its declaration bounded. Consumers that need the typed configurable route applications themselves can call createAuthApplications() from the root entry point and compose its coreRoutes, featureRoutes, and authContext applications independently.

Overview

Absolute Auth is a TypeScript-based authentication system that provides a comprehensive solution for handling user authentication in web applications. It supports multiple authentication providers and offers features such as authorization, callback handling, token refresh, token revocation, and session management.

Installation

Prerequisites

Steps to Install Dependencies

  1. Clone the repository:

    git clone https://github.com/absolutejs/auth.git
    cd auth
  2. Install the dependencies:

    bun install

Usage

Example app

A full, runnable demo lives in the AbsoluteJS examples repo under examples/auth. It shows @absolutejs/auth across all six AbsoluteJS frontends (React, Vue, Svelte, Angular, HTML, HTMX) — login, identity linking/merging, and connector grants — against one shared Elysia server.

Authentication System

Expired browser sessions

Long-lived application tabs can install the framework-agnostic session guard once during client boot. It checks the package status route when a tab becomes active, intercepts 401 responses from explicitly protected same-origin paths, and returns the person to the page they were using after sign-in:

import { installSessionExpiryGuard } from '@absolutejs/auth/client';

installSessionExpiryGuard({
	protectedPaths: ['/v1/'],
	signInPath: '/signin'
});

Installed-app authentication

createMobileAuthClient keeps the public auth surface provider-neutral while using the installed-app security model: system-browser Authorization Code with S256 PKCE, exact state/issuer/redirect validation, rotating refresh credentials in native secure storage, in-memory access tokens, serialized refresh, and an origin allowlist for bearer requests. Passwords are entered in the external authorization UI and never posted through the app WebView.

import {
	createMobileAuthClient,
	createMobileAuthTransport,
	createAuthClient
} from '@absolutejs/auth/client';
import { lifecycle, links, secureStorage } from '@absolutejs/devices';

const mobile = createMobileAuthClient({
	clientId: 'com.example.app',
	issuer: 'https://app.example',
	lifecycle,
	links,
	redirectUri: 'com.example.app:/oauth/callback',
	storage: secureStorage
});
const authClient = createAuthClient({
	transport: createMobileAuthTransport(mobile)
});

The OIDC client registration must be public (no client secret), include the exact redirect URI, permit the requested scopes/resource, and require PKCE. Browser applications continue using HTTP-only session cookies.

AbsoluteJS mobile builds provision that public client automatically when the application declares @absolutejs/auth. The CLI passes a strict ABSOLUTE_AUTH_NATIVE_CLIENTS deployment declaration into the server runtime; Auth layers matching issuer clients over oidc.clientStore without writing to the consumer's database. An explicitly stored client with the same ID remains authoritative. Applications that use Auth on mobile must mount the OIDC provider; the mobile build fails with an actionable error when it is absent. mobile.fetchOptional() is intended for application-shell/page-envelope requests: it sends a bearer token when a renewable session exists and otherwise performs a credential-free request so public pages still load before sign-in. The generated native shell installs this transport through the package-owned runtime registry, so existing createAuthClient() calls select it without application changes. Explicit transport options always win, installation is stacked and reversible, and web/server runtimes never install the registry.

When portable push is enabled, Auth also owns its authenticated installation boundary. Pass the existing Dispatch push lifecycle directly; the server derives the principal, tenant, and authorized topics and returns an opaque installation identity. The native shell handles APNs/FCM tokens, while the generated PWA runtime handles structured browser subscriptions; ordinary page code reads neither provider credential:

const authApplication = await auth({
	// ...normal Auth + OIDC configuration
	push: {
		registrar: pushLifecycle,
		tenant: (principal) => principal.user.organizationId,
		topics: (principal) => topicsFor(principal.user)
	}
});

The fixed /auth/push route accepts bearer-authenticated native clients and cookie-authenticated web clients, but never accepts user ID, tenant, or topics from either. The prior /auth/mobile/push path remains an installed-client compatibility alias. Credential rotation and deletion require the server-issued installation identity and Dispatch verifies that it belongs to the current principal. Native sign-out attempts removal while credentials still exist and always clears the local provider registration even when the network is down.

For WebSocket/Sync authentication, enable a ticket store on the provider. A valid audience-bound access token can then obtain a 30-second, hashed-at-rest, single-use ticket from /oauth2/socket-ticket:

const socketTicketStore = createPostgresSocketTicketStore(db);

await auth({
	oidc: {
		// ...normal provider configuration
		socketTicketStore
	}
});

const ticket = await mobile.socketTicket();

Run the oidc migration block after upgrading; migration 0004_socket_tickets creates the ticket table. Resource servers may use requireAuthPlugin({ accessTokens: { getUser, oidc } }) to resolve cookie sessions and bearer access tokens into the same typed authPrincipal. DPoP- bound bearer tokens currently fail closed until resource-proof verification is enabled.

The complete Auth application also publishes an absoluteAuthSync capability to later Elysia plugins. @absolutejs/sync detects it automatically: the single-use ticket on a WebSocket, the Bearer token on the finite native route, and an exact-same-origin HTTP-only browser session all resolve to the same { authPrincipal, user } context. For a browser session the bridge also derives an opaque PWA IndexedDB namespace from issuer, subject, and the optional absolutejs_sync_partition user claim; the cookie and identity never enter worker storage. Mount Auth before Sync; page and native code require no authentication wiring:

new Elysia().use(authApplication).use(syncSocket({ engine }));

The bridge is capability-based, so Auth does not depend on Sync and Sync does not depend on Auth. Sync owns the exact-Origin, Fetch Metadata, and JSON request checks before it asks Auth to resolve a cookie session. Invalid, expired, replayed, wrong-audience, cross-origin, and DPoP-bound credentials continue to fail closed.

The defaults use /oauth2/status, /signin, reason=session_expired, and a returnUrl query parameter. Use onExpired when a router or application shell should own navigation. The returned guard exposes check() for an immediate status check and dispose() for cleanup.

Optional SAML adapter

SAML route types and wiring are available from the main package. The concrete @node-saml/node-saml adapter is isolated so applications that do not use SAML do not install or bundle its XML/crypto dependencies:

import { createNodeSamlAdapter } from '@absolutejs/auth/saml';

Install @node-saml/node-saml only in applications that use this adapter.

The concrete SimpleWebAuthn adapter follows the same boundary:

import { createSimpleWebAuthnAdapter } from '@absolutejs/auth/webauthn';

Provider-managed phone verification

verificationProvider is the vendor-neutral phone-verification lifecycle. The contract supports SMS, WhatsApp, and voice-call OTP; MFA currently selects SMS while signup, recovery, phone change, and step-up flows can use the same provider contract. Auth owns enrollment, atomic resend/code-consumption policy, audit, and session promotion; the provider generates, delivers, checks, and cancels the code.

import { auth } from '@absolutejs/auth/server';
import { createTwilioVerificationProvider } from '@absolutejs/auth-twilio';
import { Twilio } from 'twilio';

const authPlugin = await auth({
	// credentials, mfa, stores, providersConfiguration, etc.
	verificationProvider: createTwilioVerificationProvider({
		client: new Twilio(
			process.env.TWILIO_ACCOUNT_SID!,
			process.env.TWILIO_AUTH_TOKEN!
		),
		verifyServiceSid: process.env.TWILIO_VERIFY_SERVICE_SID!,
		serviceTokenTtlMs: 10 * 60 * 1000
	})
});

Without a provider, mfa.onSendSmsCode keeps codes local and accepts any application-owned delivery system such as @absolutejs/dispatch. Its payload includes purpose and userId for safe templates, audit correlation, and tenant routing. Provider and local-code sends share the default 30-second per-enrollment resend cooldown; configure mfa.smsResendCooldownMs when needed. MFA enrollment, replacement, and removal require a fresh authentication by default (five minutes); configure mfa.managementAuthMaxAgeMs deliberately.

Production databases must run the mfa migration block after upgrading to add the atomic SMS challenge identifier.

Delegated AI agents

The agentAuth block provides a standards-first agent identity layer. It publishes RFC 9728 metadata, records registrations and user delegations, and adds a scoped protectAgent guard. It can also serve a generated /auth.md registration guide and matching structured OAuth metadata. This is native to @absolutejs/auth; no WorkOS service or separate package is required.

Applications using ordinary OAuth dynamic client registration can publish an agent-readable /auth.md without enabling the separate claim/ID-JAG profile. Set agentAuth.oauthGuide to the exact enabled protected resources, metadata URLs, and scopes. Auth serves the guide and advertises it through RFC 8414 service_documentation; the structured OAuth metadata remains authoritative.

Protocol-specific credentials are normalized by verifier adapters:

import {
	createInMemoryAgentDelegationStore,
	createInMemoryAgentRegistrationStore,
	createOidcAgentCredentialVerifier
} from '@absolutejs/auth/agents';

const registrationStore = createInMemoryAgentRegistrationStore();
const delegationStore = createInMemoryAgentDelegationStore();

const authPlugin = await auth({
	agentAuth: {
		authorizationServer: 'https://auth.example.com',
		delegationStore,
		registerDynamicClients: true,
		registrationStore,
		resource: 'https://api.example.com',
		scopes: ['documents:read', 'documents:write'],
		verifyCredential: createOidcAgentCredentialVerifier({
			// Atomically insert a hash of jkt + jti; return false on conflict.
			consumeDpopJti: replayStore.consume,
			issuer: 'https://auth.example.com',
			publicJwk: signingKey.publicJwk,
			requireDpop: true,
			resource: 'https://api.example.com'
		})
	},
	oidc: {
		// Enable RFC 7591 dynamic client registration and RFC 8628 device auth.
		clientRegistrationTokenStore,
		deviceAuthorizationStore
		// ...the normal OIDC provider configuration
	}
});

With registerDynamicClients enabled, an RFC 7591 client becomes an agent registration. Approval through the existing RFC 8628 device flow creates the user-to-agent delegation. Send the RFC 8707 resource value with device authorization to bind the resulting token directly to the protected API, or use RFC 8693 token exchange when narrowing an existing user token.

When requireDpop is enabled, the adapter accepts only an RFC 9449-bound access token using the DPoP authorization scheme, verifies its per-request proof and ath token hash, and requires the proof key to match cnf.jkt. Provide consumeDpopJti as an atomic shared-store insertion in clustered deployments; returning false rejects a replay. Proofs without jti, proofs whose JWK contains private key material, oversized identifiers, and htu claims containing query or fragment components fail closed. Resource servers that require RFC 9449 nonces can use the nonce helpers exported by @absolutejs/auth/oidc to issue a separate resource nonce challenge.

Client applications can use createDpopClient from @absolutejs/auth/client. It creates a non-exportable P-256 private key, signs a fresh proof for every request, adds ath and the case-sensitive DPoP authorization scheme when an access token is supplied, and retries one authorization- or resource-server nonce challenge. Give the two servers distinct nonceScope values when they share an origin. Redirects remain manual so credentials and proofs are never forwarded to an unverified location.

import { createDpopClient } from '@absolutejs/auth/client';

const dpop = await createDpopClient();
const response = await dpop.fetch(resourceUrl, {
	dpop: { accessToken, nonceScope: protectedResource },
	method: 'POST'
});
app.get('/documents', ({ protectAgent }) =>
	protectAgent(['documents:read'], (agent) => ({
		agentId: agent.agentId,
		actingFor: agent.userId
	}))
);

Postgres and Neon registration/delegation stores are exported alongside the in-memory stores. Include the agents migration block in production. runMigrations uses its existing Neon-compatible pool when given databaseUrl, or accepts an injected MigrationClient for standard Postgres drivers. Injected clients remain owned by the caller and are not closed by the migration runner.

For agents that need to create or link an account, configure agentAuth.agentRegistration with an identity-registration store, access-token store, signing key, authenticated-user resolver, and post-claim scopes. Enable service_auth or anonymous registration explicitly; anonymous registration also requires an idempotent callback that revokes every pre-claim token before ownership changes. Absolute exposes provider and consumer helpers from @absolutejs/auth/agents, including ID-JAG issuance and verification, secure RFC 9728/RFC 8414 discovery, claim polling, and assertion exchange.

See the agent-auth interoperability and deployment guide for supported standards, security invariants, and the production checklist.

OIDC and agent-registration signing accepts either a local ES256 privateJwk or a sign(input) adapter with the public JWK and key ID. Production adapters can therefore keep private key material non-exportable in a KMS or HSM. The adapter must return the 64-byte JOSE ES256 signature (r || s); DER conversion belongs at the KMS boundary.

OIDC providers can retain bounded previousSigningKeys containing public identity only. The JWKS endpoint publishes the active key first and the previous keys behind it, while every new token remains signed exclusively by the active key. Provider token exchange, introspection, userinfo, logout hints, and agent credential verification select the exact verification key named by the JWT kid. Remove each previous key only after the longest issued token using it has expired; duplicate key IDs fail closed.

Features

  • Authorization: Handles the authorization process by generating the authorization URL and redirecting the user to the authentication provider.
  • Callback Handling: Handles the callback process by validating the authorization code, decoding the ID token, and creating or retrieving the user.
  • Token Refresh: Handles the token refresh process by refreshing the access token using the refresh token.
  • Token Revocation: Handles the token revocation process by revoking the access token.
  • Session Management: Manages user sessions, including creating, retrieving, and removing sessions.

Configuration Options

  • Providers: Configure multiple authentication providers such as Google, GitHub, and more.
  • Routes: Customize the routes for authorization, callback, signout, status, refresh, and revoke.
  • Event Handlers: Define custom event handlers for authorization, callback, status, refresh, signout, and revoke events.
  • User Management: Implement custom functions for creating and retrieving users.

Note

This project uses Bun and is built for Elysia.

OAuth and API credential token routes

As of 0.79.0, apiKeysRoutes() and auth({ apikeys }) serve the client_credentials grant at /auth/api/token by default. OIDC authorization code and refresh grants continue to use /oauth2/token. This keeps separately mounted plugins from replacing each other's token handler.

Update enterprise integrations and displayed token URLs to /auth/api/token. API-only applications can retain the previous URL by explicitly setting apikeys.tokenRoute: '/oauth2/token', provided no OIDC handler uses that path.

Prefer configuring both features in auth({ oidc, apikeys }): conflicting token paths are rejected during construction, including a trailing-slash alias. When mounting standalone plugins with custom paths, the consumer must keep the paths distinct; Elysia does not reject arbitrary duplicate routes.

Persistent sessions with an existing PostgreSQL client

Use createPostgresAuthSessionStore(db, decodeUser) with an existing Drizzle PostgreSQL client. It uses the same session tables as the Neon convenience adapter; decodeUser validates the stored user shape when a session is read. Pair it with createPostgresCredentialStore(db) for persistent passwords. Your application must also persist its own user records.

import { SQL } from 'bun';
import { drizzle } from 'drizzle-orm/bun-sql';
import { createPostgresAuthSessionStore, createPostgresCredentialStore } from '@absolutejs/auth';

const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) throw new Error('DATABASE_URL is required');
const client = new SQL(databaseUrl);
const db = drizzle({ client });
const sessionStore = createPostgresAuthSessionStore(db, decodeUser);
const credentialStore = createPostgresCredentialStore(db);

Apply the sessions and credentials migrations before serving requests. runMigrations accepts a MigrationClient for non-Neon PostgreSQL drivers. On Bun, use the package-owned runner; it supports ordinary PostgreSQL over TCP (including local Docker databases) without a Neon WebSocket proxy:

import { runBunMigrations } from '@absolutejs/auth/bun';
await runBunMigrations({ databaseUrl, blocks: ['sessions', 'credentials'] });

It owns and closes a separate unprepared connection, locks concurrent migration runs, and rolls back both DDL and journal entries on failure. Keep the default prepared-query mode for the application's Drizzle connection and JSON columns. Do not implement a raw SQL migration adapter in application code. The existing runMigrations({ databaseUrl }) remains the Neon transport; custom clients remain supported for other runtimes.

Studio's absolute-auth setup reads the selected adapter from src/backend/packages/auth.config.ts. Explicit memory storage skips database migrations and warns that sessions reset on restart. The Neon adapter requires a real DATABASE_URL and runs migrations. Missing or unknown selections fail with an actionable error; custom adapters must configure their own migrations.

For a complete credentials-only Bun setup, see Persistent email/password sign-in. It includes real migration and auth configuration APIs, durable user records, and driver settings.

Separately consented connected accounts

Set bindLinkingToSession: true when using onLinkConnector or onLinkIdentity. Start authorization with an explicit intent=link_connector (or link_identity) and a named client configured with only that capability's scopes. The callback requires the same live session that started consent. Revalidate the user's current application access in the handler. Never interpret a connector callback as login.

resolveOAuthAuthorization(callbackContext) resolves provider identity and tokens without creating a login session. Check actual returned scopes before saving grants. Compose createEncryptedLinkedProviderGrantStore({store, cipher}) with a raw package grant store and createSecretCipher(serverOnlyKey) or a versioned cipher. Use the encrypted adapter for all token writes and the OAuth credential resolver; use an explicit metadata projection for browser responses. The adapter exposes plaintext only to trusted server callers and binds encrypted tokens to grant, owner, provider subject and token field. It rejects plaintext legacy rows; migrate existing rows deliberately before enabling it. Keep encryption keys outside the DB.

Create grants/bindings and audit entries in one database transaction. Serialize connection replacement/disconnection with credential execution and refresh before enabling workers: the base grant store's ordinary upsert is not a refresh/revocation compare-and-swap protocol. Removing a grant locally is distinct from revoking an entire provider application consent, which can affect other connections.

Coordinated background credentials (0.88.0)

Use createCoordinatedOAuthLinkedProviderCredentialResolver({ transaction, cipher, providersConfiguration }) for background workers. Supply an interactive Postgres transaction callback yielding a Drizzle database, not a Neon HTTP batch. Renewal and failure reporting lock the grant row; createLinkedProviderGrantStore(tx) removal takes the same lock before deleting bindings. Reauthorization must lock that grant before reading/preserving a prior refresh token. Never resurrect an old ID with a separate upsert. Owner and binding association are checked on every lease.

Call provider actions only after getAccessToken resolves: refresh is committed independently, including safe failure states. Permanent invalid grants and ambiguous 20-second renewal timeouts require reconnection. Transactions cannot make the provider exchange atomic with the database: a process crash after external rotation may still require reconnecting. An already dispatched provider request cannot be recalled by local disconnect. Provider-wide consent revocation remains a separate explicit action.

Several ways to sign in to one account

Add an identities block so Google, Microsoft, GitHub and other providers can all open the same user. Run the identities migration block to create auth_identities.

import {
	auth,
	createNeonIdentityStore,
	linkCallbackIdentity,
	resolveCallbackIdentity
} from '@absolutejs/auth';

const identityStore = createNeonIdentityStore(process.env.DATABASE_URL!);

auth<User>({
	identities: {
		identityStore,
		getUserId: (user) => user.sub,
		// Allow removing the last provider only if they can still get in another way.
		hasOtherSignInMethod: async ({ user }) =>
			(await passkeyStore.listCredentialsByUser(user.sub)).length > 0
	},
	// Signed-in people link another provider by visiting
	// /oauth2/<provider>/authorization?client=login&intent=link_identity
	onLinkIdentity: async (context) => {
		await linkCallbackIdentity({ context, identityStore, getUserId: (u) => u.sub });

		return context.redirect('/profile?linked=1');
	},
	// Reached when that provider account already belongs to someone else.
	onLinkIdentityConflict: ({ redirect }) => redirect('/profile?linked=conflict'),
	onCallbackSuccess: async (context) => {
		const { provider, providerSubject } = await resolveCallbackIdentity(context);
		const identity = await identityStore.findIdentity(provider, providerSubject);
		// …load the user by identity?.userId, then instantiateUserSession({ ...context })
	}
});

GET /auth/identities lists the caller's linked providers and DELETE /auth/identities/:id unlinks one. Linking a provider account that already belongs to another user throws AuthIdentityConflictError, which the callback routes to onLinkIdentityConflict.

Passkeys have names and can be managed by their owner: GET, PATCH (rename) and DELETE on /auth/webauthn/credentials. Sessions record their sign-in method and browser, and GET /auth/sessions returns them as { signInMethod, device: { browser, os } }.

MFA verification cooldowns

TOTP challenges allow five code checks per five-minute window by default. All of an account's authenticator factors share this budget. Recovery codes have a separate budget, so a TOTP cooldown does not prevent recovery. Configure mfa.totpMaxAttempts, mfa.backupCodeMaxAttempts, and mfa.codeAttemptWindowMs with positive integers. Successful verification clears the completed reservation unless a newer request has already used the budget. Blocked requests never extend the fixed window. Signing in again or changing an authenticator does not reset it.

A throttled challenge returns HTTP 429, Retry-After (seconds), and JSON with code: "mfa_rate_limited", factor, and retryAfterMs. Clients should display the wait time and keep recovery codes and SMS accessible. Recovery codes are opaque, case-sensitive strings; do not restrict input to eight characters.

Upgrade: Run the mfa migration block before starting updated servers; it adds auth_mfa_code_attempts. Existing totp_failed_attempts values are retained for compatibility but no longer gate verification. The built-in memory and Postgres stores implement atomic attempt reservations and single-use recovery consumption. Custom MFAStore implementations must implement claimCodeAttempt, completeCodeChallenge, and resetCodeAttempts with the atomic semantics documented on the interface. Do not use read/modify/write counter updates across server instances. Complete the server rollout before relying on the new cooldown behavior; older servers still use the legacy limit.

Session status and protected-route checks do not mutate browser session cookies. A pending MFA session remains unauthenticated, but background requests cannot clear its cookie. Expired server sessions are still removed; explicit sign-out continues to revoke the session and expire its cookie.

TOTP setup resumes an existing unfinished enrollment instead of replacing its secret. QR account labels use the chosen device name (for example, onSpark: Ember Admin), without internal user or factor IDs. Set mfa.getDefaultTotpLabel to resolve a friendly default from the authenticated user (for example, their email) when the submitted name is blank. Custom names take precedence; unfinished setup retains its original name and secret. Existing authenticator entries retain their locally saved names; users can rename them in their authenticator app. MFAStore.saveTotpEnrollment must atomically compare factors and recovery hashes and update only TOTP enrollment fields, preserving unrelated SMS state. Verification retries preserve existing recovery codes, including when adding a device. An empty backupCodes response means saved codes are unchanged. To tolerate a lost first response, newly issued codes have an AES-GCM encrypted receipt in factor JSON, replayable for ten minutes after a valid TOTP and recent sign-in. The receipt key is domain-separated and derived from the TOTP secret; consumed codes are excluded from replay. Normal verification stores hashes only.

SMS sign-in state is isolated by account, pending session, and selected phone. getSmsChallengeStore must provide durable atomic claim/finalize/consume/failure/ rollback operations for that scope. The Postgres implementation uses the auth_mfa_sms_challenges table (migration mfa/0008_scoped_sms_challenges), expires rows with their pending sessions, and clears rows on enrollment removal. Enrollment SMS state remains separate. Existing SMS codes must be requested again once the new server version is active; enrolled phones are unchanged.

Resend cooldown responses include sms_resend_cooldown, retryAfterMs, and Retry-After; successful sends include retryAfterMs and expiresAt. A separate sms_send attempt budget limits account-wide delivery attempts to ten per five minutes by default (smsSendMaxAttempts / smsSendWindowMs). It does not invalidate issued codes or block authenticator/recovery verification. SMS senders must throw on delivery failure; scoped rollback preserves the previous challenge.