Hub ID — One ID everywhere

Hub ID — Developer Integration Guide

Version: 1.0 · Issuer: https://hubid.io

Hub ID is an OIDC Provider (Authorization Code + PKCE, RS256 JWT) for the ecosystem. This guide tells you how to add Hub ID auth to your app correctly — including UX patterns and pitfalls we have already hit.

If you are an LLM assistant: a machine-readable index lives at /llms.txt and the full guide as plain Markdown at /llms-full.txt.


TL;DR

1. Register your client (one SQL row in oauth_clients).
2. Build a single auth landing page on YOUR domain (e.g. /login).
3. The page initiates Authorization Code + PKCE → hubid.io.
4. Hub ID authenticates the user and redirects back with ?code=...
5. Your server exchanges code → access_token + id_token.
6. Verify the id_token signature with /.well-known/jwks.json.

Five minutes to working auth. Read on for the parts that bite.


OIDC discovery

All endpoints are advertised at:

GET https://hubid.io/.well-known/openid-configuration
GET https://hubid.io/.well-known/jwks.json

Use these dynamically — never hard-code endpoint paths.

Purpose Endpoint
Authorization GET /oauth/authorize
Token exchange / refresh POST /oauth/token
Token revocation POST /oauth/revoke
End session (RP-Initiated) GET /oauth/end_session
User info GET /oauth/userinfo (Bearer token)
Public keys GET /.well-known/jwks.json
Configuration GET /.well-known/openid-configuration

Supported: response_type=code, grant_type=authorization_code|refresh_token, code_challenge_method=S256, id_token_signing_alg=RS256, scopes=openid profile email.


Step 1 — Register your client

INSERT INTO oauth_clients (id, client_id, client_name, redirect_uris, client_type, created_at)
VALUES (
    gen_random_uuid(),
    'my-app',
    'My App',
    '{"https://myapp.com/auth/callback"}',
    'public',
    now()
);

Step 2 — Sign-in / Sign-up button design (read this)

This is the section that exists because Calypso shipped a bug here and we do not want it to repeat.

The rule: buttons must be symmetric.

Whatever your "Sign in" button does, your "Sign up" button must do the mirror-image thing. The two flows belong to the same identity layer; users should never get a meaningfully different UX from one button vs. the other.

Pattern A — Hub ID is your only identity provider (recommended)

You don't have local accounts. Both buttons initiate OAuth directly:

[ Sign in ] →  GET /oauth/authorize?client_id=...&...
[ Sign up ] →  GET /oauth/authorize?client_id=...&...&screen_hint=signup

The screen_hint=signup parameter (Auth0-compatible) tells Hub ID to land the user on the registration page first. Hub ID preserves the OAuth pending_id so a user who clicks "Already have an account? Sign in" still completes the original flow.

Pattern B — Hub ID alongside local email/password (dual identity)

You have your own /login and /register pages with both options. Symmetry means both pages exist and both offer both methods:

/login     →  [ Sign in with Hub ID ] + email/password form  + link to /register
/register  →  [ Sign up with Hub ID ] + email/password form  + link to /login

Header buttons go to your own pages, not to Hub ID directly:

[ Sign in ] → /login
[ Sign up ] → /register

Anti-pattern — asymmetric buttons (do not do this)

[ Sign up ]  →  GET /oauth/authorize... (jumps directly to Hub ID register)
[ Sign in ]  →  /login (your own page, email/password + Hub ID)

Why this is wrong:

This was Calypso's state on 2026-04-28; the fix is to pick Pattern A or B.

screen_hint reference

Value Effect
screen_hint=login Land on /login first (this is also the default)
screen_hint=signup Land on /register first (with Sign In link to login)
screen_hint=google Skip Hub ID's login form, go straight to Google's OAuth (/auth/google/start). Used by HubID.signInWithGoogle(). Pass user_data=<url-encoded query string> alongside it to attribute the sign-up.
(omitted) Same as screen_hint=login

screen_hint is a hint for non-google values — Hub ID does not enforce the surface. Users can switch between sign-in and sign-up freely; the original OAuth request is preserved across the switch via pending_id. For screen_hint=google, Hub ID does enforce the path: it short-circuits the login form so users don't see it.

Discovery doc advertises supported values:

GET /.well-known/openid-configuration
{ ..., "screen_hint_values_supported": ["login", "signup", "google"] }

Step 3 — PKCE flow (browser)

Generate the verifier and challenge

function base64url(buf) {
  return btoa(String.fromCharCode(...new Uint8Array(buf)))
    .replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}

const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const challenge = base64url(
  await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))
);

sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('pkce_state', crypto.randomUUID());

Redirect to Hub ID

const params = new URLSearchParams({
  response_type:         'code',
  client_id:             'my-app',
  redirect_uri:          'https://myapp.com/auth/callback',
  scope:                 'openid profile email',
  state:                 sessionStorage.getItem('pkce_state'),
  code_challenge:        challenge,
  code_challenge_method: 'S256',
  // For Sign-Up button only:
  // screen_hint: 'signup',
});

window.location = `https://hubid.io/oauth/authorize?${params}`;

Exchange the code for tokens

// On https://myapp.com/auth/callback:
const url   = new URL(location.href);
const code  = url.searchParams.get('code');
const state = url.searchParams.get('state');

if (state !== sessionStorage.getItem('pkce_state')) {
  throw new Error('CSRF: state mismatch');
}

const tokens = await fetch('https://hubid.io/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  credentials: 'include',                       // sets refresh_token cookie
  body: new URLSearchParams({
    grant_type:    'authorization_code',
    code,
    client_id:     'my-app',
    redirect_uri:  'https://myapp.com/auth/callback',
    code_verifier: sessionStorage.getItem('pkce_verifier'),
  }),
}).then(r => r.json());

sessionStorage.removeItem('pkce_verifier');
sessionStorage.removeItem('pkce_state');

// tokens.access_token  — JWT, 15 min, send as Bearer
// tokens.id_token      — JWT, user profile claims
// tokens.expires_in    — seconds
// refresh_token        — set as httpOnly cookie, scoped to /oauth/token

Step 4 — Verify tokens

Always verify the id_token signature using JWKS. Never trust a JWT because it parses.

# Python (PyJWT)
import jwt, requests

JWKS = jwt.PyJWKClient('https://hubid.io/.well-known/jwks.json')

def verify(id_token: str, audience: str) -> dict:
    key = JWKS.get_signing_key_from_jwt(id_token).key
    return jwt.decode(
        id_token,
        key=key,
        algorithms=['RS256'],
        audience=audience,           # your client_id
        issuer='https://hubid.io',
    )
// Node (jose)
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(new URL('https://hubid.io/.well-known/jwks.json'));

const { payload } = await jwtVerify(idToken, JWKS, {
  issuer:   'https://hubid.io',
  audience: 'my-app',
});

Required claims to check: iss, aud, exp, iat. Hub ID's signing keys rotate; always fetch JWKS dynamically (cache 1h).


Step 5 — Get user info

For data not in the id_token (or to refresh stale claims):

GET /oauth/userinfo HTTP/1.1
Host: hubid.io
Authorization: Bearer <access_token>

Response shape depends on requested scopes:

{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "name": "Alex",
  "picture": "https://hubid.io/avatars/...",
  "email": "[email protected]",
  "email_verified": true
}
Scope Claims returned
openid sub
profile name, picture
email email, email_verified

Step 6 — Refresh tokens

Refresh tokens live in an httpOnly; SameSite=None; Partitioned cookie scoped to /oauth/token. To get a new access_token:

const tokens = await fetch('https://hubid.io/oauth/token', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'refresh_token',
    client_id:  'my-app',
  }),
}).then(r => r.json());

Token rotation: every refresh issues a new refresh token and revokes the previous one. If a revoked refresh token is presented, Hub ID assumes theft and revokes the entire token family. Don't cache refresh tokens client-side — let the cookie do its job.

Two tabs refreshing at once: the tab that loses the race presents a token another tab rotated moments ago. Within 10 seconds, from the same device and the same client, and only while the family is still alive, Hub ID answers it with a normal 200 carrying a fresh access_token and no Set-Cookie — the winning tab already put its new refresh token in the shared cookie, so both tabs keep working. The response body is the same shape as any other successful refresh. Outside that window, or from another device or client, the reuse is treated as theft and the family goes down as above.

Safari ITP fallback: if the cookie is blocked, fall back to a redirect through /oauth/authorize?prompt=none. If a session exists at Hub ID, you get an instant code. If not, you get error=login_required and should show the Sign In button.


Step 7 — Sign-out

Hub ID supports two sign-out patterns. Pick the one that matches your product. They differ in what survives the click.

Pattern A — Local sign-out (consumer SSO)

The user signs out of your app but stays signed in to the rest of the ecosystem. Click "Sign in" again at any other RP and they're back without re-entering credentials. This is what consumers expect when they have a single account spanning many products.

// 1. Revoke the refresh token (so silent-SSO can't resurrect this RP's session).
await fetch('https://hubid.io/oauth/revoke', {
  method: 'POST',
  credentials: 'include',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({ token: '<refresh_token>' }),
});

// 2. Clear your local session (cookies, localStorage, in-memory state).
location.href = '/';

The Hub ID hub_session cookie at hubid.io is not touched — by design, so the user remains signed in to other ecosystem services.

If the user wants "sign out everywhere" without leaving your app, point them at https://hubid.io/profile → "Sign out everywhere".

Pattern B — RP-Initiated Logout (admin tools, single-purpose apps)

The user signs out of your app and the Hub ID session itself. Use this when:

// Server-side handler (Next.js example):
export async function POST() {
  const endSession = new URL('https://hubid.io/oauth/end_session');
  endSession.searchParams.set(
    'post_logout_redirect_uri',
    'https://your-app.com/'
  );

  // 303 converts our POST handler into a GET on Hub ID (end_session is GET-only).
  const res = NextResponse.redirect(endSession, 303);
  res.cookies.delete('your_app_session');  // drop your local cookie too
  return res;
}

What /oauth/end_session does:

The redirect happens via top-level navigation, so the hub_session cookie is sent automatically (no CORS, no SameSite-None gymnastics) — this is why intel.zncr.pro picks Pattern B over Pattern A.

What each pattern does to access tokens

The same caveat applies to every cut-off: it is enforced where Hub ID reads the token itself. There is no introspection endpoint, so an RP that validates the JWT locally keeps accepting it until exp unless it also tears down its own session (that is what back-channel logout is for).


Production deployment

Most integration bugs we've shipped weren't logic bugs — they were deployment bugs. Three things every new integration gets wrong:

1. APP_URL env var (your service's public origin)

Behind any reverse proxy (nginx, Cloudflare, ALB, even Vercel's edge in some setups), framework "current request URL" helpers reflect the upstream address (http://localhost:3000), not the public origin (https://your-app.com). If you build OAuth redirect_uri from request.url, Hub ID rejects the token exchange with invalid_grant because the value doesn't match what the SDK sent at /authorize. If you build redirect-on-error URLs from request.url, you ship users to https://localhost:3000/login?error=... (real bug we hit).

Required: set APP_URL (or equivalent — Intel calls it PUBLIC_ORIGIN, derived from HUBID_REDIRECT_URI) and use only that when constructing externally-visible URLs:

# /srv/your-app/.env.production
APP_URL=https://your-app.com
const baseUrl = process.env.APP_URL?.replace(/\/+$/, '')
              || new URL(request.url).origin;  // dev fallback

2. Session cookie attributes

Your local app session cookie should be __Host--prefixed in production. The __Host- prefix forces Secure, forbids Domain=, and requires Path=/ — all properties you want anyway, and the prefix lets the browser reject any malformed copy a subdomain or proxy might try to set.

res.cookies.set(
  process.env.NODE_ENV === 'production' ? '__Host-myapp_session' : 'myapp_session',
  token,
  { httpOnly: true, secure: true, sameSite: 'lax', path: '/', maxAge: 7*24*3600 }
);

3. Embedded auth — client row + CORS

The embedded JSON API is gated by the client row, not by an origin env var: oauth_clients.embedded_auth_allowed must be true, and every redirect_uri you send is matched against oauth_clients.redirect_uris exactly. Your origin must also be in Hub ID's CORS_ALLOW_ORIGINS or the browser blocks the call before it reaches us.

# Hub ID's /opt/hubid/.env
CORS_ALLOW_ORIGINS=["https://your-app.com","https://hubid.io",...]
CORS_ALLOW_HEADERS=["Authorization","Content-Type",...]

Authorization must be in CORS_ALLOW_HEADERS/api/v1/profile/change-password is the first embedded endpoint that authenticates by Bearer token rather than by the hub_session cookie.

Register one client row per (product, environment) pair: app_base_url is a single value, so a shared row would send staging users links to the production domain.

Match your API's expected audience to that client_id. The embedded endpoints mint an access token whose aud is the client_id (resource indicators are a redirect-flow feature), so a product API validating a different audience rejects every request with 401 right after a successful sign-in. In creatoros_backend that setting is HUBID_AUDIENCE.

4. Symptoms checklist

If sign-in works but something else doesn't, this is usually why:

Symptom Most likely cause
invalid_redirect_uri at /authorize RP's redirect_uri not in oauth_clients.redirect_uris (exact string match)
invalid_grant at /oauth/token redirect_uri sent to /token ≠ what was sent to /authorize (usually request.url vs APP_URL mismatch)
Browser navigated to https://localhost:3000/... in prod Same as above — redirectToLogin(request, …) is using upstream URL
/oauth/userinfo returns 401 id_token claims out of sync (issuer rotation, expired); or in some old deploys, verify_jwt signature mismatch (deploy artifact missing)
SDK widget shows "Sign in" right after a successful login RP origin missing from CORS_ALLOW_ORIGINS, so the cross-origin /session XHR fails; or Hub ID runs on an http issuer, where the cookie stays SameSite=Lax and never travels cross-site
Cross-site POST returns 403 cross_site_request_rejected RP origin missing from CORS_ALLOW_ORIGINS — it is also the allowlist of the cross-site request guard. The guard answers outside CORS, so the browser console shows a CORS/network error and the body stays unreadable; the reason is in Hub ID's logs (cross_site_request_rejected)
/session works in Chrome but not in Safari / Firefox The cookie is third-party there: Safari (ITP) blocks it outright, Firefox (Total Cookie Protection) partitions it per top-level site. SameSite=None; Secure cannot fix that — use the redirect flow for those browsers until Hub ID moves under a common parent domain
Sign-out flickers and comes back logged in RP origin missing from CORS_ALLOW_ORIGINS → the cross-site guard answers /api/v1/auth/logout with 403 → cookie not cleared
client_not_embedded (403) oauth_clients.embedded_auth_allowed is false for that client
client_mismatch (403) on verify-email / reset-password The emailed token was minted for a different client_id
Emailed link points at hubid.io instead of your domain app_base_url unset, or its origin doesn't match any registered redirect_uri (we fall back rather than trust it)
client_google_not_configured (403) on /api/v1/auth/google oauth_clients.google_client_id is NULL — set it to the Google client your button uses
server_google_secret_missing (403) on /api/v1/auth/oauth/google/* Hub ID has no GOOGLE_RP_CLIENT_SECRETS entry for your client_id — a Hub ID-side configuration gap, not yours

Embedded auth (no domain switch)

Steps 1–7 above describe the redirect flow — the OAuth standard, where the user is briefly bounced through hubid.io/login. For first-party ecosystem services that prefer to keep the auth UI on their own domain, Hub ID also offers an embedded flow: a JSON API + drop-in JavaScript SDK. The user never visibly leaves your service.

The embedded flow is only available to clients flagged oauth_clients.embedded_auth_allowed=true. Third-party RPs always use the redirect flow. PKCE is still required end-to-end — embedded auth doesn't loosen the security model, it just moves the form from hubid.io onto your domain.

When to use which

Use redirect flow when… Use embedded flow when…
You're a third-party app You're first-party (same org as Hub ID)
You want minimum effort Brand consistency matters more than minimum effort
MFA / step-up is enforced You want the auth UI on your domain
The user is unauthenticated and SSO is the priority You're on mobile web where popups are awkward

You can have both wired up at once — /login page using the SDK, but a fallback link to the redirect flow if the SDK fails to load.

Drop-in SDK (one script tag)

<div id="auth"></div>
<script src="https://hubid.io/sdk/sdk.js"></script>
<script>
  await HubID.init({
    clientId:    'my-app',
    redirectUri: 'https://my-app.com/api/auth/exchange',
  });
  HubID.mountForm(document.getElementById('auth'), { mode: 'signin' });
</script>

That's it. The SDK auto-injects its own stylesheet (CSS is bundled into the JS file at serve time). No <link rel="stylesheet"> needed; no build step on the host page.

mountForm renders the email-password form plus a "Continue with Google" button on by default. On success, the SDK navigates to redirectUri?code=...&state=.... Your server completes the PKCE token exchange there.

SDK API surface

HubID.init(cfg: {
  clientId: string;          // OAuth client_id from oauth_clients
  redirectUri: string;       // Your /api/auth/exchange (or equivalent)
  issuer?: string;           // default 'https://hubid.io'
  scope?: string;            // default 'openid profile email'
  injectStyles?: boolean;    // default true — set false to use your own CSS
  onSessionChange?: (user: User | null) => void;
}): Promise<void>;

HubID.getUser(): User | null;
HubID.onSessionChange(fn): () => void;   // returns unsubscribe

// Programmatic auth — for apps that want their own form UI
HubID.signIn({email, password}): Promise<{code, state}>;
HubID.signUp({email, password, displayName?, userData?}): Promise<{code, state, user_sub}>;
HubID.signInWithGoogle({userData?}): Promise<void>;   // full-page redirect; never resolves
HubID.signOut(): Promise<void>;
HubID.checkEmail(email): Promise<{available: boolean}>;

// Drop-in widgets — for apps that want zero UI code
HubID.mountForm(el, opts): {destroy(): void; switchMode(m: 'signin'|'signup'): void};
HubID.mountAccount(el, opts): {destroy(): void};

mountForm options

{
  mode?: 'signin' | 'signup';      // default 'signin'
  showGoogle?: boolean;             // default true (Google works today)
  showMagicLink?: boolean;          // default false (backend not shipped)
  showPasswordReset?: boolean;      // default false — widget is still a stub;
                                    // the reset itself ships, see "Password reset"
  onSuccess?: (r: {code, state}) => void;  // override default redirect
  onError?: (e: HubIDError) => void;
}

mountAccount options

{
  manageUrl?: string;          // default `${issuer}/profile`
  placement?: 'auto' | 'up' | 'down';   // popover side; default 'auto'
  signedOutContent?: string | HTMLElement | null;  // shown when no session
  onSignOut?: () => void | Promise<void>;   // called after SDK signOut
}

JSON API endpoints (used by the SDK, also callable directly)

All under /api/v1/auth/*. All return JSON, never HTML, and all are subject to Hub ID's CORS allowlist, so your origin must be configured before a browser can call them. /login and /register additionally require the client to be flagged embedded_auth_allowed, and the state-changing ones set the hub_session cookie on the hubid.io apex. The two password-reset endpoints are the odd ones out: no client flag, no credentials, no session issued — see the "Password reset" section for what they do and what they deliberately do not do.

Path Method Body Success response
/session GET hub_session cookie {user: {...} \| null}
/login POST {email, password, client_id, redirect_uri, code_challenge, code_challenge_method, state, scope?, nonce?} {code, state} + Set-Cookie hub_session
/register POST {email, password, display_name?, client_id, next_url?, user_data?} {type:"ok", data:{verification_required, email}} — the code arrives from /verify-email. 503 verification_email_send_failed (with Retry-After) means the account was created but the email did not go out — show a retry, not an address error; 422 verification_email_rejected is the address one and rolls the account back
/check-email POST {email} {available: bool} (rate-limited 30/min/IP) — false only when sign-up would be refused, i.e. a verified account with a password already exists; an unverified account (sign-up re-sends its link) and a social-only account both answer true
/verify-email POST {token} + the same grant fields as /login {code, state, next_url} when a grant is supplied, else {type:"ok", data:{user, session, next_url, is_already_verified}}. Idempotent: a replay inside the link's hour returns 200 with is_already_verified: true
/resend-verification POST {email, client_id?, next_url?} 204
/request-password-reset POST {email, client_id?} 204 No Content — same answer whether or not the address exists (429 when rate-limited)
/reset-password POST {token, password} + the same grant fields as /login {code, state} when a grant is supplied, else {type:"ok", data:{email}}
/reset-password/validate POST {token} 200 {} while the reset link is alive, 404 reset_code_not_found once it is not. Never spends the token
/google POST {id_token, client_id, redirect_uri, code_challenge, state, nonce, user_data?}nonce is required (400 without it) and must be the same value you passed to Google Identity Services {code, state} + Set-Cookie hub_session
/logout POST hub_session cookie, and/or Authorization: Bearer <access_token>, and/or {client_id} {} + hub_session and refresh_token cookies cleared

Account management (Bearer access token, not the cookie — cross-site XHR from your domain cannot carry hub_session):

Path Method Body 200 Response
/api/v1/profile/change-password POST {new_password, current_password?} 204; all refresh tokens are revoked, and every hub session too — a cross-site call carries no hub_session cookie, so none can be spared. Rate-limited per user.

Changing the password of a signed-in user

POST /api/v1/profile/change-password — the one password endpoint that lives under /api/v1/profile, because it needs an authenticated caller.

Authenticate it either way:

POST /api/v1/profile/change-password
{"current_password": "old-one", "new_password": "a-new-one"}

200 {"type": "ok", "data": {"revoked": 3}}
Status error.message Meaning
200 Changed. revoked counts the refresh tokens killed
401 invalid_current_password Wrong or absent current password
401 login required / invalid token / client not allowed Caller not authenticated, not "token expired" — do not loop on refresh
422 weak_password Shorter than 8 characters
422 password_unchanged New password equals the current one
422 password_reset_required Account has no password yet (Google/Telegram-only) — send the user through /request-password-reset instead; setting a first password off a bearer token alone would let a leaked 15-minute token become permanent access
429 rate_limited 30/h per IP, 10/h per user; honour Retry-After

What it revokes. Every refresh token of that user, for every client, plus every hub session. On the cookie path the caller is handed a fresh hub_session (rotated id) and stays signed in; every other device is signed out. On the bearer path no cookie is minted — a cross-site Set-Cookie would be dropped anyway — so the calling app's own refresh token is dead too and it must run the authorization flow again.

Branded account widget (mountAccount)

Once a user is signed in, every page across the ecosystem (sidebar in aihub, header in calypso, profile chip in zencreator) needs the same "signed in as ..." UI. Instead of each app rolling its own, mount the shared widget:

<aside id="user"></aside>
<script src="https://hubid.io/sdk/sdk.js"></script>
<script>
  await HubID.init({
    clientId:    'my-app',
    redirectUri: 'https://my-app.com/api/auth/exchange',
  });
  HubID.mountAccount(document.getElementById('user'), {
    onSignOut: async () => {
      // SDK already cleared hub_session at hubid.io. Now drop your own
      // app session — only your server can erase that cookie.
      await fetch('/api/auth/logout', { method: 'POST' });
    },
  });
</script>

What it renders:

Why this widget pattern:

Updates ship from Hub ID — when we add features to the widget (Switch account, pending invites, two-factor reminders), every RP gets them without a deploy.

The aihub recipe (Next.js)

The full pattern is three small server routes plus the /login and /register pages:

/api/auth/begin     POST  generates PKCE pair, stores verifier in httpOnly
                          cookie, returns {challenge, state}
/api/auth/exchange  GET   reads verifier cookie, calls /oauth/token,
                          sets your app session, redirects to /
/api/auth/refresh   POST  rotates the access token via refresh_token cookie

The SDK calls /api/auth/begin on your domain before each sign-in/sign-up to get a challenge. After successful auth at Hub ID, it navigates back to /api/auth/exchange?code=...&state=... so your server can finish the token swap. The verifier never leaves the server-side cookie.

/api/auth/begin

export async function POST() {
  const bytes = new Uint8Array(32);
  crypto.getRandomValues(bytes);
  const verifier = Buffer.from(bytes).toString('base64url');
  const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
  const challenge = Buffer.from(digest).toString('base64url');
  const state = crypto.randomUUID();

  const cookieStore = await cookies();
  // Path-scope to /api/auth/exchange so the cookie isn't sent on every request.
  for (const [name, value] of [['hubid_pkce_verifier', verifier], ['hubid_oauth_state', state]]) {
    cookieStore.set(name, value, {
      httpOnly: true, secure: process.env.NODE_ENV === 'production',
      sameSite: 'lax', path: '/api/auth/exchange', maxAge: 300,
    });
  }
  return NextResponse.json({ challenge, state });
}

/api/auth/exchange

export async function GET(request: NextRequest) {
  // Behind a reverse proxy (nginx, Cloudflare, ALB) `request.url` reflects
  // the upstream bind address (http://localhost:3000), not the public origin.
  // The PKCE redirect_uri MUST byte-match what the SDK sent to /oauth/authorize
  // (which used window.location.origin). Mismatch → /oauth/token returns
  // invalid_grant. Read the public origin from APP_URL env, fall back to
  // request.url for direct/dev hits. See "Production deployment" below.
  const baseUrl = (process.env.APP_URL?.replace(/\/+$/, ''))
    || new URL(request.url).origin;

  const { searchParams } = new URL(request.url);
  const code = searchParams.get('code');
  const state = searchParams.get('state');

  const cookieStore = await cookies();
  const storedState = cookieStore.get('hubid_oauth_state')?.value;
  const verifier    = cookieStore.get('hubid_pkce_verifier')?.value;

  // Always clear bootstrap cookies, even on failure (no replay).
  cookieStore.delete({ name: 'hubid_oauth_state', path: '/api/auth/exchange' });
  cookieStore.delete({ name: 'hubid_pkce_verifier', path: '/api/auth/exchange' });

  if (!code || !state || state !== storedState || !verifier) {
    return NextResponse.redirect(new URL('/login?error=state_mismatch', baseUrl));
  }

  const tokenRes = await fetch(`${process.env.HUBID_URL}/oauth/token`, {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      client_id: process.env.HUBID_CLIENT_ID!,
      redirect_uri: `${baseUrl}/api/auth/exchange`,
      code_verifier: verifier,
    }),
  });
  if (!tokenRes.ok) return NextResponse.redirect(new URL('/login?error=token_failed', baseUrl));
  const { id_token, access_token } = await tokenRes.json();

  // Verify id_token signature + claims via JWKS (see Step 4). Skipping this
  // means trusting whoever sent the response — userinfo alone is not enough
  // because the access_token in your closure could have been substituted.
  const claims = await verifyIdToken(id_token, /* expected nonce */);

  // userinfo is canonical for profile fields (claims may be a subset).
  const userRes = await fetch(`${process.env.HUBID_URL}/oauth/userinfo`, {
    headers: { Authorization: `Bearer ${access_token}` },
  });
  const userInfo = await userRes.json();

  await createSession({ sub: claims.sub, email: userInfo.email, name: userInfo.name });
  return NextResponse.redirect(new URL('/', baseUrl));
}

The access_token is not stored in your session cookie. It's used once to fetch userinfo and discarded. Future API calls that need an access_token request a fresh one through /api/auth/refresh (which uses the Hub ID-domain refresh-token cookie via credentials: include).

Why returning code (not tokens) from /login

The embedded POST /api/v1/auth/login returns an authorization code, not access/refresh tokens. Your server then exchanges that code through /oauth/token exactly as the redirect flow does. This keeps PKCE as the audience-binding mechanism — even if an attacker captures the code mid-flight, they can't use it without the verifier (which lives in the RP server's httpOnly cookie). It also means the existing refresh-token-rotation logic at /oauth/token works unchanged.

Symmetric Sign-in / Sign-up buttons (still applies)

Step 2's button-symmetry rule still applies in embedded mode. With the SDK, the standard pattern is:

[ Sign in ] → /login (your page)  → HubID.mountForm(el, {mode: 'signin'})
[ Sign up ] → /register (your page) → HubID.mountForm(el, {mode: 'signup'})

switchMode() lets a user flip between sign-in and sign-up without losing the page state — the SDK preserves the in-progress state token.

Security checklist (embedded-specific)

Failure modes the SDK handles

Scenario What the user sees
Wrong password "Email or password is incorrect." (constant-time)
Email already registered (signup) "An account with this email already exists. Try signing in."
Weak password (signup, < 8 chars) "Password must be at least 8 characters."
Malformed / undeliverable email (signup) 422 invalid_email_quality — "This email address doesn't look valid. Please check it."
Disposable email (signup) 422 disposable_email — "Disposable email addresses are not allowed."
Mail provider refuses the address (signup) 422 verification_email_rejected — "We couldn't send a verification email to this address. Please use a different one." The account row exists but can never be verified, so the user must sign up with a different address.
Rate limited "Too many attempts. Try again in Ns." (Retry-After)
SDK in iframe Throws iframe_blocked — host page gets the error
SDK fails to load Host page shows fallback button to redirect flow
Hub ID returns 503 SDK throws http_503; the form shows generic error

A transient send failure (mail provider 5xx / network) is not surfaced as an error: /register still returns 200 with verification_required, because the address is fine and a resend will succeed. Hub ID's own HTML flow offers a resend button on the "check your email" page — an RP embedding the SDK must provide its own entry point to POST /api/v1/auth/resend-verification, or a user hit by that window has no way forward. The failure is logged server-side as verification_email_send_failed.

Breaking change: SDK error codes (DEV-2953)

HubIDError.code changed meaning. Before, envelope-shaped errors always produced http_<status> (http_422, http_409) because the SDK read body.error — which is an object, not a string — so every domain token was lost and every error rendered as the generic "Sign-up failed. Please try again." Now the SDK reads the domain token out of error.message when it looks like a token (^[a-z0-9_]+$), and falls back to error.code and then http_<status>.

Scenario e.code before e.code now
422 malformed email http_422 invalid_email_quality
422 weak password http_422 weak_password
409 email already registered http_409 email_taken
500 internal error http_500 http_500 (message is prose, not a token)
429 rate limited (flat body) rate_limited rate_limited (unchanged)

If you match on http_422 / http_409, update to the domain tokens above. The HTTP status is unchanged and remains the safest thing to branch on first; e.status still carries it. Server-side response bodies did not change — only how the SDK derives code.

Sign-in methods supported today

Method SDK API Status
Email + password signIn, signUp, mountForm({mode}) ✅ Shipped
Google (redirect) signInWithGoogle(), mountForm({showGoogle: true}) (default on) ✅ Shipped — full-page redirect via screen_hint=google
Google (on your domain) your own Google Identity Services button → POST /api/v1/auth/google ✅ Shipped — no hubid.io transit, and Google's consent screen names your product because the id_token is minted for your Google client (oauth_clients.google_client_id)
Google (your own button, redirect) POST /api/v1/auth/oauth/google/initiate → Google → POST /api/v1/auth/oauth/google/callback ✅ Shipped — same Google application and same sub as the GIS option, but the button is yours to design. See "6b. Google by redirect"

Google flow: user clicks "Continue with Google" → SDK redirects through /oauth/authorize?screen_hint=google → Hub ID hands off to Google's account chooser → Google redirects back to Hub ID's /auth/google/callback → Hub ID resumes the OAuth flow with a PKCE-bound auth code → your RP's /api/auth/exchange swaps it for tokens. The user briefly transits through accounts.google.com (industry norm) but never sees the Hub ID login form.

Coming in a follow-up

Both SDK options below are flagged off by default. Opting in early gives you placeholder UI that doesn't work — leave them off.

Option What's missing Status
showMagicLink: true POST /api/v1/auth/magic-link/request plus a /m/{code} consumer that issues a PKCE-bound auth code instead of just a session. Transactional email itself is no longer the blocker — Hub ID sends through Resend today. Hub ID has device-to-device magic link (a signed-in user generates a URL); the email-based passwordless flow is the missing piece.
showPasswordReset: true Only the widget. The backend and Hub ID's own reset pages shipped on 2026-06-29 — see the "Password reset" section. Turning this option on renders a "Forgot password?" link that does nothing but print "coming in the next release". Backend ✅ shipped · SDK widget ⛔ still a stub.

The backend is POST /api/v1/auth/request-password-reset then POST /api/v1/auth/reset-password. Supply the grant fields on the second call and the user is signed in straight from the emailed link — following it already proves inbox control, so a second login would be ceremony. The reset revokes every session and every refresh token for that user.

Until the widget lands, send users who need a password reset to Hub ID's hosted page at https://hubid.io/auth/forgot-password — a plain link from your own form is enough, and it is the same flow the Hub ID login page uses. Magic link has no such fallback: passwords or Google are the only sign-in paths today.



Contract reference — the six flows a first-party frontend needs

Everything below is the embedded contract: your form lives on your domain and the user never sees hubid.io. All paths are relative to the issuer (https://hubid.io), all bodies are JSON, and every call is credentials: "include" so the hub_session cookie rides along.

Grant fields. Four endpoints finish a sign-in and therefore take the same PKCE block — referred to below as grant fields:

{
  "client_id":             "zen-rp",                       // your oauth_clients row
  "redirect_uri":          "https://app.example.com/api/auth/exchange",  // must be registered verbatim
  "code_challenge":        "<base64url(sha256(verifier))>",
  "code_challenge_method": "S256",                          // optional, S256 is the default and the only accepted value
  "state":                 "<csrf token you generated>",    // echoed back verbatim
  "scope":                 "openid profile email",          // optional
  "nonce":                 "<random>"                       // optional here, REQUIRED for /google
}

They answer with a flat {code, state} — no envelope. Exchange code at POST /oauth/token (grant_type=authorization_code, plus your code_verifier) exactly as in Step 3.

Errors share one shape: {"detail": "<code>", "status_code": <n>}. Codes are listed per flow; 429 always carries Retry-After in seconds.

1. Login

POST /api/v1/auth/login — email + password, no redirect.

// request
{ "email": "[email protected]", "password": "…", ...grant fields }
// 200
{ "code": "…", "state": "…" }          // + Set-Cookie: hub_session
Code detail Meaning
401 invalid_credentials wrong email or password (constant-time)
403 email_not_verified registered but never confirmed — offer “resend”
403 client_not_embedded your client lacks embedded_auth_allowed
400 invalid_redirect_uri / invalid_request redirect_uri not registered / no code_challenge
400 invalid_client unknown client_id
429 rate_limited per-IP or per-email bucket

2. Register

POST /api/v1/auth/register — creates the account and sends the confirmation email. No code here: the user is not signed in until the address is confirmed.

// request — grant fields NOT used
{ "email": "[email protected]", "password": "…", "display_name": "Alex",
  "client_id": "zen-rp", "next_url": "/dashboard",
  "user_data": "utm_source=facebook&utm_medium=cpc&fbclid=IwAR123" }
// 200
{ "type": "ok", "data": { "verification_required": true, "email": "[email protected]" } }

user_data is optional: the raw query string the visitor landed on your page with, forwarded verbatim (a leading ? is fine). Hub ID splits it into UTM parameters and stores it as the account's ad attribution; omit it — or send an empty string — and no attribution row is written. A value longer than 4096 characters is dropped silently: the registration still succeeds with 200, only the attribution is skipped. The SDK forwards it as HubID.signUp({email, password, userData}), and the drop-in widget as HubID.mountForm(el, {userData}) — neither reads the query string on its own, because only you know which of your URL parameters are attribution and which are secrets.

Every registration path accepts it, with the same field name and the same raw format:

Path Where user_data goes
Email + password POST /api/v1/auth/register body
Google on your domain (GIS) POST /api/v1/auth/google body
Google full-page redirect GET /oauth/authorize?screen_hint=google&user_data=… (url-encoded), carried through the OAuth state to the callback
Google redirect with your own button POST /api/v1/auth/oauth/google/initiate body (step 1), carried server-side to the callback — see "6b"
Telegram login widget POST /auth/telegram/widget/callback form field — not part of Telegram's signed payload, so it is attribution only and never trusted for auth
Telegram WebApp POST /auth/telegram/webapp body

Attribution is written only when the account is created. A later sign-in through the same provider neither creates a row nor overwrites the one the registration left. Every registration reports what happened to its attribution at INFO — recorded, or skipped because the field was empty — so "no rows" and "the field never arrives" are distinguishable from the logs alone.

Code detail Meaning
409 email_taken offer sign-in instead
422 weak_password shorter than 8 characters
422 invalid_email_quality / disposable_email rejected by the mail-quality gate
429 rate_limited per-IP

client_id matters: it picks the sender domain, the product name in the letter, and the domain the confirmation link points at (app_base_url).

Availability probe while typing: POST /api/v1/auth/check-email {email}{available: bool}.

3. Email verification

POST /api/v1/auth/verify-email — call it from your /auth/verify-email?token=… page.

// request
{ "token": "<from the emailed link>", ...grant fields }
// 200 with grant fields
{ "code": "…", "state": "…", "next_url": "/dashboard", "is_already_verified": false }
// 200 without them
{ "type": "ok", "data": { "user": {…}, "session": {…}, "next_url": "/dashboard",
                          "is_already_verified": false } }

Confirming is idempotent: replaying the same token inside the link's hour returns the same 200 + Set-Cookie, with is_already_verified: true. Treat that as success, not an error — it is what makes the flow survive a mail scanner prefetching the link before the human clicks, and plain double clicks. A replay does not bump login_count and emits no duplicate analytics.

The link is not unlimited: it stops being redeemable after a handful of redemptions (enough for a scanner prefetch, a proxy fetch and clicks on two devices) and is separately rate-limited per link, so treat is_already_verified: true as a terminal success and stop re-posting it. Once the address is confirmed the link's remaining life is cut to a few minutes. Once spent, a confirmed address gets 409 email_already_verified rather than a misleading “expired”. Each accepted redemption in the embedded flow mints a fresh authorization code, so use the one from the response you acted on.

To revoke a confirmation link that is still inside its hour — e.g. the address is already confirmed and you want the emailed link dead — call POST /api/v1/auth/resend-verification for that address. For an already-confirmed user it sends no mail and kills the outstanding link.

Code detail Meaning
404 verification_code_not_found the link expired (its hour elapsed) or was redeemed too many times while the address is still unconfirmed — offer “resend”. A second click is no longer a 404
409 email_already_verified the link is spent, but the address is confirmed — tell the user to sign in, not to resend
403 client_mismatch this token was minted for another client_id
403 client_not_embedded / access_denied client not allowed to finish sign-in
400 invalid_redirect_uri / invalid_request bad grant fields
429 rate_limited too many attempts. Two buckets: a per-link one (rejected grants do not spend it, so retrying a bad client_id cannot lock the user out) and a coarse per-client-address one

A rejected grant does not burn the token — the client is validated before a redemption is spent, so a bad client_id or redirect_uri leaves the link intact and the user can retry it.

Resend: POST /api/v1/auth/resend-verification {email, client_id, next_url?}204 (429 rate_limited when spammed). Always 204, even for unknown addresses.

4. Password reset

Two calls. Ask: POST /api/v1/auth/request-password-reset {email, client_id}always 204, including for addresses that don't exist (anti-enumeration). 429 when spammed.

The emailed link lands on your own page when the client has app_base_url and password_reset_path set (e.g. https://app.example.com/auth?reset-token=…); with password_reset_path unset it stays on <app_base_url>/auth/reset-password?token=…, which is what every client got before DEV-3285. Then, from that page:

// request
{ "token": "<from the emailed link>", "password": "<new>", ...grant fields }
// 200 with grant fields
{ "code": "…", "state": "…" }          // + Set-Cookie: hub_session — the user is signed in
// 200 without them
{ "type": "ok", "data": { "email": "[email protected]" } }
Code detail Meaning
404 reset_code_not_found expired or already used
422 weak_password shorter than 8 characters — token survives, let them retry
403 client_mismatch token minted for another client_id

Optional pre-flight: POST /api/v1/auth/reset-password/validate {token} answers 200 {} while the link is alive and 404 reset_code_not_found once it is not, without spending the token — use it to show "this link expired" before rendering the form. It reports nothing about the account and does not check client_id, so a token minted for another client still validates and only fails with client_mismatch on /reset-password.

Following the emailed link proves inbox control, so with grant fields the reset signs the user in directly — no second login screen. The reset kills every other session and every refresh token, so other devices are logged out.

5. Password change (signed-in)

POST /api/v1/profile/change-password — the only call here authenticated by Authorization: Bearer <access_token>, because a cross-site XHR cannot carry the cookie.

// request
{ "current_password": "<old>", "new_password": "<new>" }   // current_password omitted only for social-only accounts
// 204 — no body
Code detail Meaning
401 missing/invalid Bearer, or a token minted for a non-embedded client
403 invalid_current_password wrong current password
422 weak_password shorter than 8 characters
429 rate_limited per-user bucket

Revokes all refresh tokens and all hub sessions, so re-run your sign-in flow afterwards — the access token you hold keeps working only until it expires (≤15 min).

6. Google

Render Google's own button on your page with your product's Google client (oauth_clients.google_client_id), then post the id_token it returns:

// request — nonce is REQUIRED and must equal the one you passed to Google Identity Services
{ "id_token": "<Google id_token>", ...grant fields, "nonce": "<same as GIS>" }
// 200
{ "code": "…", "state": "…" }          // + Set-Cookie: hub_session
Code detail Meaning
400 nonce_required you didn't send a nonce
401 invalid_google_token bad signature, wrong aud/iss, expired, or nonce mismatch
401 google_token_replayed this id_token was already used — mint a fresh one
401 google_email_unverified Google says the address isn't confirmed
403 client_google_not_configured oauth_clients.google_client_id is empty for your client
503 google_jwks_unavailable Google unreachable — retry after Retry-After

The user never transits hubid.io and Google's consent screen names your product, because the id_token is minted for your Google client.

6b. Google by redirect (draw your own button)

Same Google application, same sub, same {code, state} answer — but no Google Identity Services script and no Google-styled button. You draw the button, Hub ID builds the Google URL, your page catches the code and posts it back. Two calls.

Step 1 — initiate. Send your grant plus the domain the browser is on:

// POST /api/v1/auth/oauth/google/initiate
{
  "client_id": "your-client-id",
  "redirect_uri": "https://app.example.com/api/auth/exchange",  // where OUR code goes
  "code_challenge": "<S256 challenge>",
  "frontend_domain": "https://app.example.com",                 // where GOOGLE returns
  "state": "<your state>",                                      // optional, echoed in step 2
  "nonce": "<your nonce>",                                      // optional
  "user_data": "utm_source=…&fbclid=…"                          // optional, ad attribution
}
// 200
{
  "authorization_url": "https://accounts.google.com/o/oauth2/v2/auth?...",
  "state": "<GOOGLE state — put it nowhere but the redirect>"
}
// + Set-Cookie: google_flow  (HttpOnly; 10 min; scoped to /api/v1/auth/oauth/google)

Send the browser to authorization_url. Send the request with credentials included (fetch(..., {credentials: "include"})) — the google_flow cookie is what ties step 2 back to this browser, and without it step 2 returns invalid_flow_binding. That cookie is the defence against someone finishing their own Google login and replaying the code into your user's browser.

Step 2 — callback. Google returns to https://app.example.com/auth/google/callback?code=…&state=…. Post exactly those two:

// POST /api/v1/auth/oauth/google/callback   (credentials: "include")
{ "code": "<Google code>", "state": "<the Google state from step 1>" }
// 200
{ "code": "…", "state": "<YOUR state from step 1>" }   // + Set-Cookie: hub_session

Nothing else in the body is read. client_id, redirect_uri, code_challenge and user_data were stored server-side in step 1 and cannot be swapped in between. Attribution is written only when the account is created, and reported at INFO under path=google_rp_redirect.

The two state values are different things. The one step 1 returns belongs to the Google round trip; the one step 2 returns is yours, echoed back untouched.

Code detail Meaning
400 invalid_frontend_domain frontend_domain is not an origin of any registered redirect_uri
400 invalid_redirect_uri the OIDC redirect_uri is not registered for your client
400 invalid_state unknown, expired (10 min) or already-used Google state — start again at step 1
400 invalid_flow_binding the google_flow cookie is missing or does not match — send credentials, start again at step 1
400 google_code_rejected Google refused the code (already spent, or wrong redirect_uri)
400 invalid_client no such client_id
403 client_google_not_configured oauth_clients.google_client_id is empty for your client
403 server_google_secret_missing Hub ID has no Google secret configured for your client — a Hub ID configuration gap, tell us
503 google_unavailable Google was unreachable during the exchange

Every error on step 2 means starting again at step 1. The Google state is spent the moment the callback is read, and a Google code is single-use — retrying the same call can never succeed, which is why no Retry-After is offered.

Operational prerequisite: https://<your domain>/auth/google/callback must be listed as an authorized redirect URI in your Google application, and Hub ID must hold that application's client secret in its own configuration (never in the database).

Logout

POST /api/v1/auth/logout200 {} and the hub_session cookie is cleared. This is a local sign-out: the user stays signed in to other ecosystem products. Drop your own app session in the same handler. To end the Hub ID session itself use GET /oauth/end_session (Step 7); to sign the person out on every device use POST /api/v1/profile/logout-everywhere.

Worked example — register through to signed in

# 1. register (sends the email)
curl -sX POST https://hubid.io/api/v1/auth/register -H 'Content-Type: application/json' \
  -d '{"email":"[email protected]","password":"averysecret123","client_id":"zen-rp",
       "user_data":"utm_source=facebook&fbclid=IwAR123"}'
# → {"type":"ok","data":{"verification_required":true,"email":"[email protected]"}}

# 2. user clicks https://app.example.com/auth/verify-email?token=XYZ — your page calls:
curl -sX POST https://hubid.io/api/v1/auth/verify-email -H 'Content-Type: application/json' \
  -d '{"token":"XYZ","client_id":"zen-rp","redirect_uri":"https://app.example.com/api/auth/exchange",
       "code_challenge":"<challenge>","state":"<state>"}'
# → {"code":"AUTH_CODE","state":"<state>","next_url":"/dashboard"}

# 3. your server swaps the code for tokens
curl -sX POST https://hubid.io/oauth/token \
  -d grant_type=authorization_code -d client_id=zen-rp -d code=AUTH_CODE \
  -d redirect_uri=https://app.example.com/api/auth/exchange -d code_verifier=<verifier>
# → {"access_token":"…","id_token":"…","expires_in":900}

Silent SSO (second app, automatic sign-in)

Once a user is signed in to one ecosystem service, the next service can sign them in with zero clicks:

SPA opens → fetch /oauth/token (credentials: include)
            → if refresh_token cookie valid: instant tokens, signed in.
            → if not: redirect with prompt=none.
                → session exists at Hub ID: instant code, signed in.
                → no session: error=login_required, show Sign In button.

Code:

async function trySilentSSO() {
  // Path 1: refresh cookie
  const rt = await fetch('https://hubid.io/oauth/token', {
    method: 'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({ grant_type: 'refresh_token', client_id: 'my-app' }),
  });
  if (rt.ok) return rt.json();

  // Path 2: prompt=none redirect (Safari fallback)
  const params = new URLSearchParams({
    response_type: 'code',
    client_id:     'my-app',
    redirect_uri:  'https://myapp.com/auth/callback',
    scope:         'openid profile email',
    prompt:        'none',
    code_challenge: /* ... */,
    code_challenge_method: 'S256',
    state: /* ... */,
  });
  window.location = `https://hubid.io/oauth/authorize?${params}`;
}

Password reset

Live since 2026-06-29. Two different things share the name "password reset" — keep them apart:

Mechanism Status
Hosted flow — Hub ID's own pages plus the JSON endpoints below ✅ Shipped
Embedded SDK widgetmountForm({showPasswordReset: true}) ⛔ Still a stub. The link only prints "coming in the next release". Leave the option off.

A signed-in user changes their password through POST /api/v1/profile/change-password (Bearer access token) instead — this reset flow is for the case where the old password is unavailable.

Hosted flow (nothing to integrate)

If you use the redirect flow this already works and you write no code — Hub ID's login page carries a "Forgot password?" link. If you host your own form, link to https://hubid.io/auth/forgot-password yourself.

/login (Hub ID)  →  "Forgot password?"
    →  GET  /auth/forgot-password         user types their email
    →  POST /auth/forgot-password         always renders "Check your email"
    →  email → https://hubid.io/auth/reset-password?token=…
    →  GET  /auth/reset-password?token=…  new-password form
    →  POST /auth/reset-password          302 → /login?reset=success
Page Method Behaviour
/auth/forgot-password GET Email form.
/auth/forgot-password POST (email) Always 200 "Check your email" — same page whether or not the address has an account, and whether or not the request hit the rate limit (over the limit it silently sends nothing).
/auth/reset-password?token=… GET New-password form. Rendered optimistically; the token is checked on submit, not here, so a dead link still shows the form.
/auth/reset-password POST (token, password, confirm) 302 → /login?reset=success. 400 with an inline error if the two fields differ or the password is under 8 characters — the token survives both, the user just resubmits. 404 "link expired" if the token is unknown, already used, or older than an hour.

The user does not come back to your app. The "Forgot password?" link carries no next parameter, and the post-reset redirect to /login?reset=success carries no client context, so a user who started the reset from your app finishes it on hubid.io and stays there. They re-enter through your own Sign in button. Nothing is broken — the flow simply isn't resumable, so don't build UX that assumes a round trip.

Following the emailed link proves control of the inbox, so an account that never confirmed its verification email becomes verified here. A social-only account (Google, no password yet) gains a password this way — the flow sets a credential rather than replacing one.

The reset email carries a fixed Reset your password subject. It goes out from the client's own sender when that client has email_from configured (pass client_id on /request-password-reset), and from Hub ID's default sender otherwise.

What the reset does and does not revoke

A successful reset destroys every Hub ID SSO session (hub_session) for that account, so a stolen browser session is evicted. What happens next depends on whether you supplied the grant fields:

It does not touch tokens already issued to relying parties. Access tokens stay valid until they expire and refresh tokens keep working. A refresh-token family is dropped by POST /oauth/revoke (RFC 7009), by the profile actions POST /api/v1/profile/revoke-site and POST /api/v1/profile/logout-everywhere, by revoking consent for a site from the Hub ID profile, and by the reuse detector described under Step 6 (replaying a rotated token kills the family). A password reset triggers none of these.

So if you treat a password reset as compromise recovery, do the RP half yourself: invalidate your own session and drop the refresh token you hold. Hub ID will not do it for you, and a stolen refresh token outlives the reset.

JSON API

These are the two endpoints the hosted pages call. Unlike /login and /register they need no hub_session cookie, no client credentials and no embedded_auth_allowed flag — but they are ordinary /api/v1/auth/* endpoints, so a browser call still has to come from an origin on Hub ID's CORS allowlist. Server-to-server calls are unrestricted.

You can call /request-password-reset to trigger the mail, and you can host your own reset form: for a client with embedded_auth_allowed and an app_base_url whose origin matches one of its registered redirect_uris, the emailed link is built from that base and lands on your page. Pass the grant fields to /reset-password and you get {code, state} back. Set password_reset_path on the client (admin → client settings) to choose the path on that base; the code then arrives as ?reset-token=. It is only accepted together with a trusted app_base_url and embedded_auth_allowed (422 otherwise) — without those the emailed link stays on hubid.io and the setting would never be honoured. Pick the path by which query parameter your page reads — ?reset-token= is fixed, so the path has to be the one that reads it. For the ZenCreator/OpenMov frontend that is /auth, not /reset-password: /reset-password there reads ?token= and renders a dead form for anything else. Leave it unset and the link keeps the older <app_base_url>/auth/reset-password?token= shape. Without a usable app_base_url the link falls back to Hub ID's own ISSUER_URL and points at hubid.io/auth/reset-password?token= — we fall back rather than trust an unverified base, and password_reset_path is ignored there because the hosted page is the only page that exists on that domain.

POST /api/v1/auth/request-password-reset — body {email}

204 No Content on success. An unknown address is a silent no-op, so the response can't be used to probe which emails have accounts. Requesting a new link invalidates the previous one — exactly one reset token is live per account at a time.

POST /api/v1/auth/reset-password — body {token, password}

Status Body When
200 {"type": "ok", "data": {"email": "[email protected]"}} Password changed. No session issued.
422 {"type": "error", "error": {"code": "validation_error", "message": "weak_password", "details": {}}} Under 8 characters. Validation runs before the token is consumed, so the same link still works — let the user retry.
404 {"type": "error", "error": {"code": "not_found", "message": "reset_code_not_found", "details": {}}} Token unknown, already used, or expired.
429 {"error": "rate_limited"} + Retry-After header Rate limit hit.

POST /api/v1/auth/reset-password/validate — body {token}

Status Body When
200 {} Token is live. It is only read, never consumed — the later /reset-password with the same token still succeeds.
404 {"type": "error", "error": {"code": "not_found", "message": "reset_code_not_found", "details": {}}} Token unknown, already used, or expired.
429 {"error": "rate_limited"} + Retry-After header Rate limit hit (60 per 10 min per IP, a separate budget from the one that sends mail).

Tokens are single-use and live for one hour.

Rate limits: /request-password-reset is capped at 5 per hour per IP and 5 per day per email address (the second bucket stops someone flooding one victim's inbox). /reset-password is capped by the per-IP bucket only.

That per-IP bucket is shared — by both endpoints and by the hosted pages. Five requests per hour from one IP across all of them, not five each. It is a fixed window rather than a sliding one, so traffic straddling a window boundary can briefly get through at up to twice the nominal rate.

The two endpoints report a rate limit differently, which matters if you handle 429 programmatically. /reset-password behaves like /login, /register and /check-email: body {"error": "rate_limited"} plus a Retry-After header. /request-password-reset instead returns the standard error envelope — {"type": "error", "error": {"code": "too_many_requests", "message": "rate_limited", "details": {}}} — and carries no Retry-After header (it shares this quirk with /resend-verification). Don't rely on that header being present here; back off on your own schedule.


Errors

All OAuth errors follow RFC 6749 §5.2.

Error Meaning Action
invalid_client Unknown client_id Check your client registration
invalid_redirect_uri redirect_uri not in allowlist Add it to oauth_clients.redirect_uris
invalid_grant Bad code, expired, or PKCE mismatch Re-initiate flow
login_required prompt=none but no session Show Sign In button
unsupported_response_type Only code is supported Use response_type=code
service_unavailable Hub ID DB unreachable; do not retry to redirect_uri (RFC 6749 §4.1.2.1) Show error UI, retry-after 30s

Security checklist


Common mistakes

  1. Skipping PKCE because "we use a confidential client". PKCE is required for all clients in Hub ID — including server-side ones. The code_verifier protects against code interception independent of client_secret.
  2. Hard-coding /oauth/token. Always read the discovery document.
  3. Trusting the id_token without signature verification. A self-signed JWT with the right claim shape will pass jwt.decode if you skip algorithms=['RS256'] and key=....
  4. Storing access_token in localStorage. XSS = full account takeover. Keep it in a closure / runtime variable; let the refresh cookie do the persistence.
  5. Asymmetric Sign in / Sign up buttons. See Step 2.
  6. Calling /oauth/revoke and assuming logout is global. It's local; the Hub ID session at hubid.io survives. Use /oauth/end_session for RP-Initiated logout — see Step 7 Pattern B. It ends that browser's session and its products, not every device: that is logout-everywhere.
  7. Building redirect_uri from request.url in production. Behind a reverse proxy this is the upstream bind, not your public origin — token exchange fails with invalid_grant. Use APP_URL env. See Production deployment §1.
  8. Forgetting to add your prod origin to CORS_ALLOW_ORIGINS. Sign-in keeps working (it's a top-level redirect), but every embedded JSON call from your page is blocked by the browser. See Production deployment §3.
  9. Sharing one client row across environments. app_base_url is a single value, so staging users get production links in their email. One row per (product, environment).

LLM-friendly resources

Path Format Purpose
/llms.txt text Index per llmstxt.org
/llms-full.txt markdown Full guide concatenated, plain text
/docs/integration.md markdown This document, raw
/docs html This document, rendered
/.well-known/openid-configuration json OIDC discovery (machine-readable)
/.well-known/jwks.json json Public signing keys

If you are an LLM helping a developer integrate Hub ID, the typical sequence is:

  1. Fetch /.well-known/openid-configuration for endpoints.
  2. Fetch /llms-full.txt for narrative + code samples.
  3. Run the snippets in Step 3–6 with the developer's actual client_id and redirect_uri.
  4. Make sure the Sign in / Sign up button design follows Step 2 — that is the most-skipped piece.

Versioning and changelog

This guide is versioned with Hub ID itself. Breaking changes ship as new sections in the discovery document; non-breaking additions appear without notice. The canonical changelog is at https://hubid.io/docs/changelog (Phase 2+).


Support

Signing out a product

POST /api/v1/auth/logout ends the Hub ID session and, for the product that asked, stops the access tokens it was already issued. Tell us which product you are — the cookie alone does not say:

await fetch('https://hubid.io/api/v1/auth/logout', {
  method: 'POST',
  credentials: 'include',
  headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ client_id: 'zen-rp' }),
});

An Authorization: Bearer <access_token> header works too, and is the only option for a caller that has no cookie (server-side, CLI, cross-site XHR). The SDK's signOut() already sends client_id.

Status detail Meaning
200 The hub session is closed. Tokens are closed too when the request named a product
400 invalid_client The client_id in the body is not a registered client — nothing was changed at all, fix the value rather than retrying
503 logout_failed The cut-off could not be recorded. The product's refresh family is already revoked and its cookie is not cleared, so a silent refresh will answer invalid_grant; the session is still alive. Honour Retry-After and send the same request again

/oauth/end_session and POST /api/v1/profile/logout-everywhere answer the same 503 with the same Retry-After for the same reason. Every sign-out does its retryable work first and destroys the session last, so a failure in the middle always leaves a state the identical request can be sent into again.

A sign-out that failed never extends the browser session. Any answer that reports an error — the 503 above, the 400, or an unexpected 500 — carries no fresh hub_session cookie, so the cookie keeps its original expiry instead of getting another 24 hours, and the browser stops sending it on that clock. The rule is not specific to sign-out: no response with a status of 400 or above re-issues the session cookie, on any endpoint. (The server-side session TTL is a separate, sliding clock and is not shortened by this — the cookie is what bounds the session in practice.)

What a sign-out does, in order:

  1. Revokes that client's refresh-token family and clears the refresh_token cookie.
  2. Records a cut-off for (user, your client_id). Every access token of that user for that client minted at or before the sign-out second stops being accepted by Hub ID's own bearer endpoints (/oauth/userinfo, /api/v1/profile/change-password, /api/credits/*) — immediately, not after its 15 minutes. There is no introspection endpoint: an RP that validates the JWT locally, as the guide recommends, keeps accepting it until exp, so pair this with your own session teardown (see back-channel logout below).
  3. Sends back-channel logout to your backchannel_logout_uri, if registered. The logout_token carries sid only when a browser session ended, and a frontend_domain claim (from Origin) so one backend serving several brands can scope which of its own sessions to end.
  4. Destroys the hub_session (when the request carries the cookie), so /oauth/authorize?prompt=none answers login_required instead of quietly handing out a new token.

Deliberate boundaries: