@iqauth/sdk
v2.8.1
Published
TypeScript SDK for IQAuth — the canonical way for all IQ projects to integrate with IQAuthService
Readme
@iqauth/sdk
The canonical TypeScript SDK for IQAuthService — DispositionIQ's multi-tenant identity and authorization platform. One package covers the React client, the four major Node frameworks (Express, Fastify, Hono, Next.js), native mobile, and headless service automation.
New in 2.7.0 —
IQAuthErrornow exposes a typedcodetaxonomy (token_expired,jwks_unavailable,rate_limited, …) pluserr.is(code)/IQAuthError.isIQAuthError(value)helpers, andtokens.verify<T>()accepts a custom-claims generic so the returned object is fully typed. Both changes are purely additive — existingcatch (e: Error)and untypedverify()callers keep compiling. See What's new in 2.7.0.
Table of contents
- Install
- Five-line integration
- Pick your environment
- What's new in 2.7.0
- What's new in 2.6.5
- What's new in 2.6.2
- What's new in 2.6.1
- What's new in 2.0.3
- Browser apps with a backend (recommended)
- Server reference (Express, Fastify, Hono, Next.js)
- Token verification without a framework adapter
- Native mobile (PKCE)
- Service automation / API keys
- Realtime: WebSocket upgrade verification
- Integration testing with
createTestIssuer - Hosted auth pages and branding
- CLI
- Error handling
- Troubleshooting
- Bundled docs
- License
Install
npm install @iqauth/sdk- Node >= 18 (the SDK uses native
fetchand Web Crypto). - Ships CJS + ESM + .d.ts for every entry point.
- React is an optional peer (
>=18). You only need it if you import from@iqauth/sdk/react.
You'll need two values from the IQAuth admin dashboard for any app you integrate:
- Publishable key (
pk_live_…/pk_test_…) — safe to ship to the browser. Self-describes{iss, appId, tenantId, kid}, so the SDK auto-discovers your issuer; you almost never need to set it manually. - Secret key (
sk_live_…/sk_test_…) — server-only. Keep it in env vars, never in client code.
Create both in one call from the admin Quickstart wizard, or run npx iqauth init (see CLI).
Five-line integration
React (browser)
import {
IQAuthProvider,
IQAuthLoading,
IQAuthLoaded,
SignedIn,
SignedOut,
RedirectToSignIn,
} from "@iqauth/sdk/react";
export default function App() {
return (
<IQAuthProvider publishableKey={import.meta.env.VITE_IQAUTH_PUBLISHABLE_KEY}>
<IQAuthLoading><Spinner /></IQAuthLoading>
<IQAuthLoaded>
<SignedIn><Dashboard /></SignedIn>
<SignedOut><RedirectToSignIn /></SignedOut>
</IQAuthLoaded>
</IQAuthProvider>
);
}Wrapping the gating components in <IQAuthLoading/> / <IQAuthLoaded/> is
the slow-network-safe pattern: until bootstrap() finishes, both
<SignedIn/> and <SignedOut/> render null, which on a slow mobile
connection is several seconds of blank page. The loading slot fills that
gap, mirroring Clerk's <ClerkLoading/> / <ClerkLoaded/>.
Available hooks: useUser(), useSession(), useAuth(), useOrganization(). Each returns { data, isLoading, error }.
Drop-in components: <SignIn/>, <SignUp/>, <UserButton/>, <UserProfile/>, <OrganizationSwitcher/>, <AuthCallback/>.
Silent SSO is opt-in as of 2.6.1.
<SignIn/>always renders the form on first paint by default — even for returning users with an active issuer-sideiq_ssosession. To restore the old auto-resume behavior, addsilentSsoto the provider or a specific<SignIn/>instance:<IQAuthProvider publishableKey={…} silentSso>or<SignIn silentSso />. See "What's new in 2.6.1" below.
Express
import express from "express";
import { iqAuth } from "@iqauth/sdk/express";
const app = express();
app.use(express.json());
const auth = iqAuth({
publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!,
secretKey: process.env.IQAUTH_SECRET_KEY!,
});
auth.attachHelpers(app); // mounts /api/iqauth/{callback,refresh,signout} (HttpOnly cookies)
app.use(auth); // verifies Bearer header OR iqauth_at cookie; populates req.authFastify
import Fastify from "fastify";
import { iqAuth } from "@iqauth/sdk/fastify";
const app = Fastify();
const auth = iqAuth({ publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!, secretKey: process.env.IQAUTH_SECRET_KEY! });
await app.register(auth.plugin); // mounts helpers + decorates request.authHono
import { Hono } from "hono";
import { iqAuth } from "@iqauth/sdk/hono";
const app = new Hono();
const auth = iqAuth({ publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!, secretKey: process.env.IQAUTH_SECRET_KEY! });
app.route("/api/iqauth", auth.helpers);
app.use("*", auth.middleware);Next.js (App Router)
// app/api/iqauth/[...iqauth]/route.ts
import { iqAuth } from "@iqauth/sdk/next";
const auth = iqAuth({ publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!, secretKey: process.env.IQAUTH_SECRET_KEY! });
export const { GET, POST } = auth.handlers;// In any Server Component / Route Handler / Server Action
import { getAuth } from "@iqauth/sdk/next";
const session = await getAuth(); // null when signed-outThe middleware is cookie-aware (Bearer header OR iqauth_at cookie) and the issuer is auto-discovered from your publishable key — no extra config.
Pick your environment
The SDK is not "one auth model for every environment". The safe pattern depends on where credentials live.
| Environment | Recommended pattern | Refresh-token owner | Entry point |
|---|---|---|---|
| First-party browser app with a backend | Backend proxy + HttpOnly cookies | Backend only | @iqauth/sdk/{express,fastify,hono,next} + @iqauth/sdk/react (with serverManagedSession) |
| First-party browser app without a backend (SPA only) | Authorization-code + PKCE in-browser, refresh in JS-readable cookie | Browser (in cookie) | @iqauth/sdk/browser + @iqauth/sdk/react |
| Native mobile | Authorization-code + PKCE, secure OS storage | Mobile app secure storage | @iqauth/sdk/mobile |
| Server-side / API resource server | Bearer verification via JWKS | Server (per request) | @iqauth/sdk/server |
| Service / automation / cron | API key | Service | @iqauth/sdk/service |
Rule of thumb: if your app has its own backend, use a framework adapter and turn on
serverManagedSession: truein the React provider. Don't store refresh tokens inlocalStorage/sessionStorageas your durable session model.
What's new in 2.7.0
1. Typed IQAuthError taxonomy
Every SDK-originated throw now carries a code from a fixed 10-value union,
so callers can stop string-matching on err.message or guessing whether
err.code is upper-snake or lowercase. Two helpers ship alongside it:
IQAuthError.isIQAuthError(value) (instanceof-safe across realms) and
err.is(code) (narrow-friendly).
import { IQAuthError, type IQAuthErrorCode } from "@iqauth/sdk";
try {
const claims = await client.tokens.verify(token);
} catch (err) {
if (IQAuthError.isIQAuthError(err)) {
if (err.is("token_expired")) return refreshAndRetry();
if (err.is("jwks_fetch_failed")) return retryAfterBackoff();
if (err.is("rate_limited")) return showRateLimitToast();
if (err.is("network")) return showOfflineBanner();
if (err.is("config_invalid")) throw err; // boot-time misconfig
}
throw err;
}The full union:
type IQAuthErrorCode =
| "token_expired"
| "token_invalid"
| "jwks_unavailable"
| "jwks_fetch_failed"
| "rate_limited"
| "network"
| "config_invalid"
| "app_not_found"
| "permission_denied"
| "unknown";Back-compat: the field is widened to IQAuthErrorCode | (string & {}),
so server-rethrown codes (TOKEN_REVOKED, SESSION_EXPIRED_INACTIVITY, …)
still flow through unchanged. The framework adapters (/express,
/fastify, /hono) map both upper-snake and the new lowercase codes to
401, so this rollout is invisible to existing app code. IQAuthError
also gains a cause accessor (alias for the legacy raw).
See docs/error-handling.md for the full
recipe book.
2. IQAuthClaims<T> generic on tokens.verify
verify() now accepts a custom-claims generic so your app's bespoke
claims show up typed on the result — no index-signature widening, no
as any:
interface MyClaims { plan: "free" | "pro"; orgId: string }
const claims = await client.tokens.verify<MyClaims>(token);
// ^? IQAuthBaseClaims & MyClaims & JwtClaims
if (claims.plan === "pro") doProThing(claims.orgId);
console.log(claims.tenantId, claims.sub); // base claims still typedIQAuthBaseClaims is exported separately for callers composing their own
envelope. JwtClaims continues to be exported and remains the return
type of tokens.decode() / tokens.getClaims() for back-compat. Calls
to verify() without a generic argument behave exactly as before.
What's new in 2.6.5
Server-managed userinfo (mountUserinfo: true)
The framework adapters can now auto-mount GET /api/iqauth/me so
server-managed integrators don't have to hand-roll a userinfo handler
that calls tokens.verify and shapes a data.user / data.claims
envelope.
app.use(iqAuth({
publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!,
secretKey: process.env.IQAUTH_SECRET_KEY!,
mountUserinfo: true,
// Optional — shallow-merged over the claim-derived SessionUser defaults.
userinfoEnricher: async (claims, req) => ({
name: await loadDisplayName(claims.sub),
}),
}));Returns the documented UserinfoResponse envelope —
{ success: true, data: { user, claims, tenantId } } — which is exactly
the shape the browser SDK's SessionManager.bootstrap() already accepts
(data.user is preferred; claimsToSessionUser(data.claims) is the
fallback). The token is read from Authorization: Bearer … OR the
iqauth_at cookie; verification reuses a per-issuer cached
TokensModule so JWKS fetches are amortized.
Two new framework-neutral exports for integrators who'd rather mount
their own route but still emit the canonical envelope:
buildUserinfoResponse(claims, { enrich? }) and
handleUserinfo(config, { accessToken, req? }). Re-exported from both
@iqauth/sdk and @iqauth/sdk/server. See the
Server-managed userinfo section below for
the full reference.
What's new in 2.6.2
Card grows responsively on desktop (no more phone-sized form)
.iqauth-sdk-card used to cap at max-width: 460px at every viewport,
which meant a desktop user staring at a 1440px monitor saw a thin mobile
column floating inside a 720px-wide pane. The cap is now responsive:
- mobile (default): 480px
- tablet / desktop (
@container ≥ 768px): 540px - extra-wide (
@container ≥ 1280px): 580px
Inner header/body padding and the title size also bump up at the desktop
breakpoint so the form actually fills its half of the split layout. No
opt-in required — the change applies to every consumer of <SignIn/> and
<SignUp/> after upgrading.
What's new in 2.6.1
1. Silent SSO is now opt-in (default off)
Previously, <SignIn/> would detect an active issuer-side iq_sso session
on mount and silently redirect through /oidc/sso-resume — users never saw
the form, never clicked anything, and were transparently signed back in.
That was surprising for embedded use cases and made it impossible to switch
accounts without reaching for ?prompt=login. As of 2.6.1, silent SSO is
disabled by default and must be explicitly enabled:
// Provider-wide opt-in (covers every <SignIn/> below it)
<IQAuthProvider publishableKey={…} silentSso>
…
</IQAuthProvider>
// Per-instance opt-in (overrides the provider value)
<SignIn silentSso />When silent SSO is off (default), <SignIn/> always renders the form on
first paint regardless of iq_sso cookie state. prompt="login" and
?prompt=login continue to work as additional force-form switches.
2. Embedded card no longer forces full-viewport height
The internal .iqauth-sdk-pane declared min-height: 100vh unconditionally,
which worked for hosted full-page sign-in but broke when <SignIn/> was
embedded in a card, modal, or sidebar — pushing content below the fold and
creating large empty areas. The 100vh height now applies only inside the
wide side-by-side layout (@container iqauth-sdk (min-width: 768px)) where
the hero pane needs height parity. Narrow embeds size naturally to their
content.
3. Better error reporting on misconfigured <SignIn/>
useIQAuthSignInContextnow detects HTML responses (CORS preflight, wrongiqAuthBaseUrl, wrongappKey) and prints an actionableconsole.errornaming the three likely causes — instead of the crypticUnexpected token '<' in JSON.- The "returnTo not in allowed origins" console error now also lists the
app's actual
allowedOrigins, so the diff with the rejectedreturnTois visible at a glance instead of requiring a Network-tab spelunk.
For older versions see CHANGELOG.md.
What's new in 2.0.3
1. serverManagedSession: true for SessionManager / IQAuthProvider
For any app whose backend uses one of the framework adapters (@iqauth/sdk/{express,fastify,hono,next}), the backend owns the HttpOnly iqauth_at + iqauth_rt cookies and should be the sole authority on token rotation. Opt into that explicitly:
<IQAuthProvider
publishableKey={import.meta.env.VITE_IQAUTH_PUBLISHABLE_KEY}
serverManagedSession
>
…
</IQAuthProvider>What changes when this is on:
bootstrap()learns session state with a single read-onlyGET /api/v1/auth/me(override withuserinfoPath) instead of POSTing to/refresh. No rotation, no race surface.- The proactive-refresh timer is suppressed — the server middleware refreshes on real navigation, single-flight per request.
- The browser
tokenStoredefaults to a no-op store, so JS never tries to read the (HttpOnly, invisible) refresh cookie.
This eliminates the multi-tab + React StrictMode + proactive-timer race that previously produced silent forced sign-outs.
2. clearCookiesOnRefreshFailure: "terminal-only" | "always" | "never"
The cookie helper handleRefresh (used by every framework adapter) now defaults to "terminal-only". Cookies are only cleared when the issuer signals the session is unrecoverable:
TOKEN_REVOKED,SESSION_REVOKED,INVALID_GRANTUSER_DEACTIVATED,USER_DISABLED,TENANT_SUSPENDED- HTTP
410 Gone
Transient failures — TOKEN_INVALID from a rotated-out token, TOKEN_EXPIRED, network blips, 5xx — return 401 with cookies intact, so the next legitimate request can either succeed against a still-valid access cookie or be redirected to sign-in cleanly by the middleware.
iqAuth({
publishableKey, secretKey,
clearCookiesOnRefreshFailure: "terminal-only", // default; "always" restores pre-2.0.3 behavior
});3. Next.js 15+ async cookies
getAuth() in @iqauth/sdk/next now awaits next/headers#cookies() — fixes the runtime warning and the occasional null-session on the first request after build on Next 15 / 16.
Browser apps with a backend (recommended)
This is the integration path you want for any product with its own server. The browser never sees a refresh token; the backend owns rotation; cookies are HttpOnly.
// client/src/main.tsx
import { IQAuthProvider, SignedIn, SignedOut, RedirectToSignIn } from "@iqauth/sdk/react";
createRoot(document.getElementById("root")!).render(
<IQAuthProvider
publishableKey={import.meta.env.VITE_IQAUTH_PUBLISHABLE_KEY}
serverManagedSession
>
<SignedIn><App /></SignedIn>
<SignedOut><RedirectToSignIn /></SignedOut>
</IQAuthProvider>
);// server/index.ts
import express from "express";
import { iqAuth } from "@iqauth/sdk/express";
const app = express();
app.use(express.json());
const auth = iqAuth({
publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!,
secretKey: process.env.IQAUTH_SECRET_KEY!,
});
auth.attachHelpers(app); // mounts /api/iqauth/{callback,refresh,signout}
app.use(auth); // populates req.auth on every request
app.get("/api/me", (req, res) => res.json(req.auth));That's the whole integration. The hosted sign-in page on auth.dispositioniq.com redirects back to /api/iqauth/callback, which sets HttpOnly cookies and bounces the user to your return_to. Subsequent API calls succeed against the access cookie; on the rare expiry the same cookie endpoint refreshes silently.
Calling your own API from the browser
Use auth.fetch() from the React provider — it adds credentials and retries once on 401:
import { useAuth } from "@iqauth/sdk/react";
function ProductList() {
const { fetch } = useAuth();
const { data } = useQuery({
queryKey: ["products"],
queryFn: () => fetch("/api/products").then(r => r.json()),
});
// …
}Calling another IQAuth-protected API (cross-origin)
Add the calling app's origin to the target app's app_allowed_origins in the admin dashboard. The unified CORS allow-list (per-app origins ∪ global cors_origins ∪ CORS_ORIGINS env) refreshes within 60 seconds.
Server reference
Middleware behavior
- Accepts
Authorization: Bearer <jwt>or theiqauth_atHttpOnly cookie. - Verifies RS256 against the issuer's JWKS (cached with stale-while-revalidate).
- Populates
req.auth(Express/Fastify),c.get("auth")(Hono), orgetAuth()return value (Next). - The shape of
req.authis the verified JWT claims plus normalized{ sub, email, tenantId, roles, permissions, scopes }.
The SDK uses
req.auth(notreq.user) so it never collides with Passport.
Requiring roles or entitlements
app.use("/admin",
auth.require({
roles: ["tenant_admin", "platform_admin"],
entitlements: ["iqcapture"],
}),
);auth.require() is composable — chain it on individual routes, route groups, or globally. Failures throw IQAuthError with code: "INSUFFICIENT_PERMISSIONS" and the middleware returns 403.
Helper routes mounted by attachHelpers / register / auth.handlers
| Method | Path | Purpose |
|---|---|---|
| GET | /api/iqauth/callback | Receives the OIDC redirect, exchanges the code, sets iqauth_at + iqauth_rt, redirects to return_to |
| POST | /api/iqauth/refresh | Reads iqauth_rt, rotates, sets new cookies. Honors clearCookiesOnRefreshFailure |
| POST | /api/iqauth/signout | Revokes the refresh token upstream and clears both cookies |
| GET | /api/iqauth/me | Opt-in via mountUserinfo: true. Verifies the access token and returns the documented userinfo envelope. See Server-managed userinfo |
All three set cookies with HttpOnly; Secure; SameSite=lax; Path=/ by default. Override per-app:
iqAuth({
publishableKey, secretKey,
cookieDomain: ".example.com", // for cross-subdomain SSO
sameSite: "none", // pair with secure: true
secure: true,
cookiePath: "/",
accessCookieName: "iqauth_at",
refreshCookieName: "iqauth_rt",
});Server-managed userinfo
When you're doing the cookie-managed pattern (browser SDK proxies through
your own backend), your frontend needs some endpoint to learn "who am I"
on first paint. Before 2.6.5 you had to hand-roll that handler — call
tokens.verify, shape a data.user envelope, and remember to read the
token from either Authorization: Bearer … OR the iqauth_at cookie.
Opt into the auto-mounted route instead:
app.use(iqAuth({
publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!,
secretKey: process.env.IQAUTH_SECRET_KEY!,
mountUserinfo: true,
// Optional — shallow-merged over the claim-derived SessionUser defaults.
userinfoEnricher: async (claims, req) => ({
name: await loadDisplayName(claims.sub),
}),
}));GET /api/iqauth/me returns the documented UserinfoResponse envelope:
{
"success": true,
"data": {
"user": { "sub": "...", "email": "...", "name": "...", "tenantId": "...", "roles": [...], "entitlements": [...] },
"claims": { /* full verified JWT payload */ },
"tenantId": "ten_..." // or null
}
}This is exactly the shape SessionManager.bootstrap() already accepts:
data.user is preferred when present, claimsToSessionUser(data.claims)
is the documented fallback. Same option works on Express, Fastify, Hono,
and the Next.js handler. Token is verified with a per-issuer cached
TokensModule so JWKS fetches are amortized across requests.
Want to mount your own route but still emit the canonical envelope? Use the framework-neutral helpers:
import { buildUserinfoResponse, type UserinfoResponse } from "@iqauth/sdk/server";
const envelope: UserinfoResponse = await buildUserinfoResponse(verifiedClaims, {
enrich: (c) => ({ name: lookupName(c.sub) }),
});Token verification without a framework adapter
If you're not using Express/Fastify/Hono/Next (custom Node server, AWS Lambda, Cloudflare Worker, etc.):
import { createServerClient } from "@iqauth/sdk/server";
const client = createServerClient({
publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!, // issuer auto-discovered
});
const claims = await client.tokens.verify(bearerToken);
// → { sub, email, tenantId, roles, permissions, scopes, iss, aud, exp, … }tokens.verify() enforces RS256, checks exp / nbf / iss / aud, and uses the cached JWKS. Throws IQAuthError with codes like TOKEN_EXPIRED, TOKEN_INVALID, TOKEN_REVOKED.
Native mobile (PKCE)
import { createMobileClient } from "@iqauth/sdk/mobile";
const client = createMobileClient({
publishableKey: PUBLISHABLE_KEY,
redirectUri: "myapp://auth/callback",
storage: {
get: (k) => SecureStore.getItemAsync(k),
set: (k, v) => SecureStore.setItemAsync(k, v),
delete: (k) => SecureStore.deleteItemAsync(k),
},
});
// 1. begin
const { url } = await client.signIn.start();
await WebBrowser.openAuthSessionAsync(url, "myapp://auth/callback");
// 2. handle redirect
await client.signIn.complete(redirectUrl);
// 3. use the session
const me = await client.users.me();Tokens are written to whichever storage adapter you provide — use Keychain on iOS, Keystore / EncryptedSharedPreferences on Android. Never persist them to AsyncStorage / localStorage.
Service automation / API keys
For cron jobs, batch scripts, server-to-server calls — anything that isn't acting on behalf of an interactive user:
import { createServiceClient } from "@iqauth/sdk/service";
const client = createServiceClient({
publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!,
apiKey: process.env.IQAUTH_API_KEY!,
});
const users = await client.users.list({ tenantId: "…" });
const key = await client.apiKeys.create({ name: "nightly-sync", scopes: ["users:read"] });API-key calls are scoped to the permissions granted at creation time and are subject to per-key rate limits. Rotate with client.apiKeys.rotate({ id }); revoke with client.apiKeys.revoke({ id }).
Realtime: WebSocket upgrade verification
@iqauth/sdk/ws exposes a single verifyWsUpgrade(req, options) helper for Node WebSocket servers. Same option shape as the framework middlewares — publishableKey, audience, issuer, clockTolerance, cookie name. Returns { claims } on success or null on missing/invalid/expired tokens.
It accepts a token from any of:
Authorization: Bearer <jwt>header.- The
iqauth_atcookie on the upgrade request (override withcookieName). - The
Sec-WebSocket-Protocolsubprotocol valueiqauth.bearer.<jwt>— browserWebSocketcan't set custom headers, so the convention is to publish the token as a subprotocol value alongside the real one (e.g.new WebSocket(url, ["iqauth.bearer." + token, "graphql-transport-ws"])).
import { WebSocketServer } from "ws";
import { verifyWsUpgrade } from "@iqauth/sdk/ws";
const wss = new WebSocketServer({ noServer: true });
httpServer.on("upgrade", async (req, socket, head) => {
const result = await verifyWsUpgrade(req, {
publishableKey: process.env.IQAUTH_PUBLISHABLE_KEY!,
audience: "dispositioniq",
});
if (!result) {
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
socket.destroy();
return;
}
wss.handleUpgrade(req, socket, head, (ws) => {
wss.emit("connection", ws, req, result.claims);
});
});A consumer can replace IQValidate's hand-rolled packages/shared/src/websocket-server.ts authenticateUser() with one import and one call.
Integration testing with createTestIssuer
@iqauth/sdk/test spawns an in-process HTTP server that exposes a JWKS endpoint, an OIDC discovery doc, a token endpoint that accepts code-exchange calls, and a /api/v1/auth/me userinfo endpoint. It mints valid RS256 JWTs against a freshly generated keypair, so integration tests don't need a live IQAuth.
import { createTestIssuer } from "@iqauth/sdk/test";
let issuer;
beforeAll(async () => { issuer = await createTestIssuer({ port: 0 }); });
afterAll(async () => { await issuer.close(); });
it("admin can list users", async () => {
const token = issuer.mintToken({ sub: "u1", roles: ["tenant_admin"] });
const r = await fetch("http://localhost:3000/api/users", {
headers: { Authorization: `Bearer ${token}` },
});
expect(r.status).toBe(200);
});Point your SDK / React provider at issuer.baseUrl — or pass issuer.publishableKey, which already encodes the right iss. To exercise the full code-exchange flow:
const code = issuer.mintAuthCode({ sub: "u1", roles: ["tenant_admin"] });
const tokens = await fetch(`${issuer.baseUrl}/oidc/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ grant_type: "authorization_code", code }),
}).then((r) => r.json());Out of scope: the test issuer does not mock SSO providers (Google, Microsoft) and does not replicate the full DispositionIQ admin/permissions API surface — it implements exactly enough to satisfy the SDK's verify path and the common code-exchange flow.
Hosted auth pages and branding
Sign-in, sign-up, forgot-password, and account pages are hosted at auth.dispositioniq.com. They render with your app's brand once you set CSS variables in the admin dashboard:
--brand-primary--brand-accent--brand-bg--brand-surface--brand-text
Validate the redirect contract from your own page with GET /api/public/apps/:appKey/sign-in-context — only return_to origins listed in your app's allowlist will be accepted.
If you'd rather embed the same components in your own app shell, the React entry point publishes them directly:
import { SignIn, SignUp, UserButton, UserProfile, OrganizationSwitcher } from "@iqauth/sdk/react";CLI
npx iqauth init # bootstrap a new app + write IQAUTH_* keys to .env
npx iqauth doctor # check .env, issuer reachability, JWKS, redirect URI
npx iqauth keys list --app <id>
npx iqauth keys rotate --app <id> --key-id <id> --yes
npx iqauth keys revoke --app <id> --key-id <id> --yes
npx iqauth dev # run the bundled React example with your keyinit is the fastest way to provision a new app end-to-end: it creates the OIDC client, manifest, per-app origin allowlist, and a pk_/sk_ keypair, then writes them to .env. Key rotation has a 24-hour grace window — old and new keys both validate during that window.
Error handling
Every API failure throws IQAuthError with a stable code.
import { IQAuthError, ErrorCodes } from "@iqauth/sdk";
try {
await client.auth.login(email, password);
} catch (err) {
if (err instanceof IQAuthError) {
switch (err.code) {
case ErrorCodes.ACCOUNT_LOCKED: return showLockoutBanner();
case ErrorCodes.MFA_REQUIRED: return promptMfa(err.details);
case ErrorCodes.TENANT_SELECTION_REQUIRED: return promptTenant(err.details.tenants);
case ErrorCodes.INVALID_CREDENTIALS: return showWrongPassword();
default: return showGenericError(err.message);
}
}
throw err;
}Common codes you'll handle:
| Code | When |
|---|---|
| INVALID_CREDENTIALS | Wrong email/password |
| ACCOUNT_LOCKED | Too many failures; show cool-off |
| MFA_REQUIRED | Continue to MFA step with details.mfaToken |
| TENANT_SELECTION_REQUIRED | User belongs to >1 tenant; pick one |
| TOKEN_EXPIRED | Access token aged out; refresh handled by SDK helpers |
| TOKEN_INVALID | Token malformed or rotated out (transient under multi-tab) |
| TOKEN_REVOKED / SESSION_REVOKED | Terminal — sign the user out |
| USER_DEACTIVATED / USER_DISABLED / TENANT_SUSPENDED | Terminal — sign out |
| INSUFFICIENT_PERMISSIONS | 403 — caller lacks required role/entitlement |
Troubleshooting
npx iqauth doctor is the first stop. It validates that the IQAUTH_* env vars are present and well-formed, the issuer responds, the JWKS endpoint serves keys, and your registered redirect URI matches what the SDK will send.
| Symptom | Likely cause |
|---|---|
| Silent sign-out within ~60s of login | Pre-2.0.3 cookie-clearing on transient TOKEN_INVALID. Upgrade to ≥ 2.0.3, set serverManagedSession: true on the React side. |
| getAuth() returns null after first navigation in Next 15+ | Pre-2.0.2 sync-cookies bug. Upgrade to ≥ 2.0.2. |
| /api/iqauth/callback returns 400 "redirect_uri mismatch" | Your registered redirect URI doesn't match the host the browser is on. Add it in the admin app config. |
| Cross-origin call to a sister IQAuth-protected app gets CORS-blocked | Add the caller's origin to the target app's app_allowed_origins. Allow up to 60s for the cache to refresh. |
| req.auth is undefined under Passport | Verify you're reading req.auth, not req.user. The SDK deliberately uses req.auth to avoid Passport collision. |
| Hosted sign-in page won't accept your return_to | The origin isn't in this app's allowlist. Add it in the admin dashboard. |
| Server logs [AUTH-MW] Missing auth credentials on GET /api/v1/auth/me at app boot — once, immediately followed by a successful [OIDC] SSO resume issued code AND the next /auth/me returns 200 | Benign. SDK's bootstrap() probe. See "Why does the server log Missing auth credentials at startup?" below. |
| [AUTH-MW] Missing auth credentials repeats in a loop (warn → resume → token → warn → resume → token …), user can't actually finish login | NOT benign. The SDK's callback handler on YOUR app's domain isn't completing the token exchange, so no cookie ever gets set. See "When the bootstrap loop never ends" below — almost always a middleware-ordering bug on the consumer app. |
| Same Missing auth credentials warning on /api/v1/auth/me but no subsequent SSO resume issued code or /oidc/token for the same user within ~1s | Real problem — your fetch is dropping cookies. Check credentials: "include", CORS Access-Control-Allow-Credentials: true, SameSite/COOKIE_DOMAIN. |
Why does the server log Missing auth credentials at startup?
When <IQAuthProvider> mounts, the SDK's bootstrap() runs GET /api/v1/auth/me once to ask the issuer "do I already have a session on this app?" That call is defined to be anonymous when the user has never signed in here yet — there is no iqauth_at cookie scoped to your origin yet to send.
So the very first request you'll see for a returning user is:
WARN [AUTH-MW] Missing auth credentials GET /api/v1/auth/me origin=https://your-app.com
INFO [OIDC] SSO resume issued code userId=… clientId=iq_…
INFO [AuthMetrics] /oidc/token latencyThat sequence is the happy path for silent SSO: probe → no session here → resume from the issuer's iq_sso cookie → exchange code for tokens → cookie now set on your origin → subsequent requests succeed against the cookie. The warning was misleading log severity for a defined-anonymous endpoint, and as of the next IQAuth server release the bootstrap probe on /auth/me and /auth/refresh is logged at debug rather than warn. Other routes still log at warn — so seeing this message on, say, GET /api/v1/users remains a real signal that cookies aren't traveling.
If you're on an older IQAuth server and want to silence the noise without an upgrade, add a server-side log filter on the [AUTH-MW] Missing auth credentials line where route matches /api/v1/auth/(me|refresh). Don't blanket-mute the message — you'll lose visibility on real CORS/cookie failures.
When the bootstrap loop never ends
If you see Missing auth credentials → SSO resume issued code → /oidc/token happen on repeat and the user never actually gets signed in, the issuer side is doing its job perfectly — the failure is on your server. The SDK's callback helper at /api/iqauth/callback is responsible for taking the ?code= from the OAuth redirect, POSTing it to /oidc/token, and setting the iqauth_at cookie on your app's domain. If that handler doesn't run (or returns an error), no cookie ever gets set and the SDK's next bootstrap probe is anonymous again — forever.
The single most common cause: your own auth middleware is intercepting /api/iqauth/callback before attachHelpers() can handle it. The OAuth return trip is a fresh GET from the issuer with no Authorization header (correctly so — that's the whole point of the callback), so an app.use(requireAuth) mounted globally will reject it as 401.
Confirm with these signals — if you see any of them, this is your bug:
- Browser:
GET https://your-app.com/api/iqauth/callback?code=…returns 401 (or any 4xx other than 302) - Browser DevTools → Application → Cookies for your app's domain: no
iqauth_atcookie present after a sign-in attempt - Your app's server log shows your own auth middleware rejecting
path=/iqauth/callbackor/api/iqauth/callbackwith "missing authorization header"
Fix — pick one:
// ✅ Option 1 (recommended) — mount SDK helpers BEFORE your auth middleware.
const auth = iqAuth({ publishableKey, secretKey });
auth.attachHelpers(app); // public: /api/iqauth/{callback,refresh,signout}
app.use(yourAuthMiddleware); // anything below here requires a session
// ✅ Option 2 — exempt /api/iqauth/* from your own auth gate.
app.use((req, res, next) => {
if (req.path.startsWith("/api/iqauth/")) return next();
return yourAuthMiddleware(req, res, next);
});After the fix you should see /api/iqauth/callback return 302, a Set-Cookie: iqauth_at=… header on the response, the cookie appearing under your app's domain in DevTools, and the next /api/v1/auth/me returning 200. The Missing auth credentials warning drops to the one harmless boot probe per fresh visitor.
Don't intercept /sign-in?code=…
If your app uses an SSO bridge route (e.g. your-app.com/sign-in redirects users to auth.dispositioniq.com) and that same route is also the configured OAuth redirect_uri, your bridge logic will fire on the return trip and immediately bounce the user back to the issuer before the SDK can exchange the ?code= for tokens. Symptoms: an infinite redirect loop, or a successful login that the app never sees.
Pick one:
Recommended — use a dedicated callback path. Configure
redirect_urito point at/api/iqauth/callback(the helper route mounted byiqAuth({...}).attachHelpers(app)for Express, or byiqAuth({...})for Next/Fastify/Hono). Your/sign-inroute stays purely a "send user to the issuer" bridge.If you must reuse
/sign-inas the redirect target, guard the bridge:app.get("/sign-in", (req, res, next) => { // OAuth return trip — let the SDK handle it, don't redirect away. if (req.query.code || req.query.error) return next(); return res.redirect(buildIssuerAuthorizeUrl(req)); });…and add
inlineCallback: trueto youriqAuth({...})options so a GET handler is mounted on the callback path to complete the exchange and 302 to the final destination.
This is the single most common bug we see when teams add IQAuth to an app that already had its own session redirect logic.
Migrating from Clerk's backend SDK
If you're moving from @clerk/backend to @iqauth/sdk, the surface is intentionally close but not identical. The full audit lives at docs/backend-sdk-parity.md; the high-leverage deltas:
- Vocabulary. Clerk's
organizationis IQAuth'stenant.Clerk.users.banUser(id)↔iqauth.users.deactivate(id);unbanUser↔reactivate. The IQAuth modules aretenants,memberships,roles,invites— together they cover what Clerk packs intoorganizations. - Tenant scoping is explicit.
users.createtakes(tenantId, data)rather than implying it from the API key. List endpoints (users.list,tenants.list,memberships.listForTenant) accept tenant filters. - Pagination + filters are still partial.
users.list({ email, tenantId })andtenants.list({ vendorId })work today, but Clerk-style{ limit, offset, query, orderBy }is on the near-term roadmap. Bulk-import scripts that page through tens of thousands of users should call the REST API directly until the helpers ship. - Admin-side user mutations.
users.update(...)in this SDK currently updates only the calling user (name, picture). Admin-on-behalf-ofupdate/delete/verifyPasswordand apasswordDigestimport path forusers.createare tracked as follow-ups; until then, use the REST API or the admin console. - Invitations.
invites.create / validate / acceptare present.invites.list(pending) andinvites.revokeare not in the SDK yet — same for the equivalent organization-invitation calls; tracked as follow-ups. - Sessions.
sessions.list / revoke / revokeAllare scoped to the current user. There is no admin-sidesessions.getSessionList({ userId })yet; tracked as a follow-up. - Webhook signature verification. Clerk wraps Svix's
Webhook.verify(payload, headers). IQAuth ships endpoint CRUD + delivery history + secret rotation, but receivers must verify HMAC signatures by hand today. Awebhooks.verifySignature(...)helper is in flight under the existing rotation task. - Actor / impersonation tokens. Tracked separately (see internal task F23 / #86). Not yet in the SDK.
- Won't do. JWT templates and per-instance domain CRUD are Clerk-specific and don't map to IQAuth's product surface. See the audit doc for documented alternatives.
For anything in the "won't do" / "different shape" rows of the audit, prefer the linked alternative over reaching back into REST — those alternatives are the supported path.
Bundled docs
Long-form integration guides ship inside the npm tarball at node_modules/@iqauth/sdk/docs/. List them with:
ls node_modules/@iqauth/sdk/docs
ls node_modules/@iqauth/sdk/docs/guides
ls node_modules/@iqauth/sdk/docs/integration-promptsHighlights:
docs/APP_INTEGRATION_MATRIX.md— which entry point + pattern for which app archetypedocs/FRESH_IMPLEMENTATION_GUIDE.md— green-field walkthrough for a new appdocs/BROWSER_SESSION_MIGRATION.md— moving a browser-token-owning app to cookie-managed sessions (now includes the 2.0.3serverManagedSessionrecipe)docs/V1_TO_V2_UPGRADE_GUIDE.md— upgrading from1.xdocs/TARBALL_RELEASE_WORKFLOW.md— internal: shipping prebuilt tarballs to consumer appsdocs/guides/auth-flows.md,session-management.md,mfa-enrollment.md,roles-and-permissions.md,scoped-authorization.md,entity-hierarchy.md,tenant-management.md,user-management.md,invitations.md,branding.md,webhooks.md,entitlements.md,api-keys.md,mobile-native.md,server-platform-integration.md,service-automation-integration.md,token-verification.md,middleware-reference.md,error-handling.md,gdpr-compliance.md,app-registration.mddocs/integration-prompts/{first-party-browser-app,native-mobile-app,server-platform-app,service-automation-app,install-from-tarball,migrate-from-local-packages-source}.md— drop-in prompts for AI-assisted integration
These are also kept on the IQAuth admin dashboard's documentation tab.
License
Proprietary — DispositionIQ internal use. See the LICENSE/usage terms in the bundled docs.
Deploying with Docker / Next.js (read this before you ship)
The publishable key is baked into your build. NEXT_PUBLIC_* and VITE_* variables are inlined at build time, not run time. A docker image built against your staging issuer will continue to point at staging even when you later run it with -e NEXT_PUBLIC_IQAUTH_PUBLISHABLE_KEY=pk_live_xyz — Next.js / Vite have no way to re-resolve the constant after the bundle is emitted.
Two safe patterns:
Build the image per-environment (recommended for separate prod/stage stacks). Use ARG and ENV in the Dockerfile and pass --build-arg NEXT_PUBLIC_IQAUTH_PUBLISHABLE_KEY=pk_live_xyz at docker build time.
Read the key at request time on the server (single image, multi-env): fetch the publishable key in getServerSideProps / a Next.js Route Handler / your Express bootstrap and pass it to as a runtime prop. Server-side env vars ARE re-read on every container start, so the same image deploys to any environment.
Verify the build before you push: the iqauth doctor CLI compares the issuer encoded in your publishable key against the issuers discovery document and warns when they disagree:
npx iqauth doctor --issuer https://auth.example.com --publishable-key $NEXT_PUBLIC_IQAUTH_PUBLISHABLE_KEYWire this into CI right after next build and youll catch a wrong-environment publishable key before the image ships.
