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()
);
client_type='public'for SPAs / mobile (no client_secret, PKCE required).client_type='confidential'for server-side apps (client_secret + PKCE).redirect_urisis an array — list every callback URL (includinglocalhostfor development).
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:
- A new user cannot use the local email/password path; only existing users can.
- If Hub ID is briefly down, registration is fully blocked but sign-in still works for cached sessions — a confusing failure mode.
- Two buttons with the same semantic intent ("create or access an account") produce two different surfaces. Users who click the wrong one get stuck.
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:
- Your app is admin-only or otherwise security-critical (you don't want a shoulder-surfer hitting any ecosystem app via lingering SSO).
- Your app is the user's only entry point to the ecosystem.
- Your security review requires "log out actually logs out".
// 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:
- Destroys the
hub_sessioncookie + Redis session entry athubid.io. - Validates
post_logout_redirect_uriorigin against any of your registeredredirect_uris(origin-only match — landing path is free). Mismatched/missing → falls back to Hub ID's own/login. - 302-redirects the browser to the validated
post_logout_redirect_uri, preserving anystatequery param you passed.
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
- Pattern A (
/oauth/revoke) drops the refresh token only. The access tokens already handed out keep working until they expire (15 min default), and thehub_sessioncookie at hubid.io is not touched. - Pattern B (
/oauth/end_session) ends the provider session and closes the products that session signed into: for each of them a refresh-family revoke and a cut-off. Both cookies are cleared. Another browser of the same person keeps working — this endpoint is reached by a top-levelGETcarrying nothing but the session cookie, so a wider radius would let any page sign the user out everywhere. Without the refresh half the sign-out survived a singlePOST /oauth/token: the surviving cookie minted a token issued after the cut-off, and access came back in full. POST /api/v1/auth/logoutcloses exactly one product — see "Signing out a product" below.
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:
hub_sessioncookie — same-site callers and Hub ID's own pages;Authorization: Bearer <access_token>— cross-origin SPAs, whose browser will not attach the cookie (it isSameSite=Lax). The token's audience must be a first-party client: registered internally and flaggedembedded_auth_allowed. Dynamically registered (DCR) clients and multi-audience MCP tokens are rejected — a stranger who registers a client must not be able to change your users' passwords.
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:
- Idle state (~50px tall): avatar + name + email + chevron. One row, fits a compact sidebar.
- Click trigger → popover with
Manage account ↗(opens Hub ID profile in a new tab) andSign out. Brand mark "● Powered by HubID" at the popover footer. - Outside-click or Esc dismisses the popover.
Why this widget pattern:
- Progressive disclosure — the always-visible state is just identity, not actions (Apple HIG).
- Touch target ≥48px, focus-visible 2px outline, ARIA
role=menu+aria-expanded/aria-haspopup. - Auto-flip placement: the SDK measures viewport space and opens the popover up or down so it's never clipped.
- Respects
prefers-reduced-motion.
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)
- [x] Client must have
embedded_auth_allowed=trueinoauth_clients - [x]
CORS_ALLOW_ORIGINSon Hub ID is an explicit allowlist (no*) - [x]
redirect_uriis matched exactly against the client's registered list on every embedded call - [x] Emailed tokens are bound to the
client_idthat minted them - [x]
app_base_urlmust share an origin with a registeredredirect_uri, so it can't become an open redirect - [x] Rate limit per IP and per email on
/login(sliding window, Redis) - [x] 150 ms artificial floor on
/loginresponse (timing-side-channel resistance) - [x]
/check-emailreturns the same body shape regardless of email existence, and answers from the same route resolver/registerdispatches on — the two endpoints cannot disagree about whether an address can be signed up - [x] SDK refuses to run inside an iframe (
window.top !== window.self) - [x] Audit log row written for every login/register/logout (
auth_eventstable) - [x] PKCE verifier in httpOnly cookie, never in
localStorage - [x]
access_tokennot persisted in your app's session cookie - [x] Hub ID's
hub_sessioncookie:SameSite=None; Secure; HttpOnlyin prod so cross-origin XHR from RP domains can read the session. Partitioned (CHIPS) is intentionally NOT used — it would shard the cookie per top-level site and break SSO across ecosystem RPs. On an http issuer (localhost dev) the cookie falls back toSameSite=LaxwithoutSecure, because Chrome discardsSameSite=Nonecookies that are notSecure. - [x] Because the session cookie is
SameSite=None, cross-site requests that carry it are checked against an origin allowlist (CORS_ALLOW_ORIGINSplus Hub ID's own issuer): unsafe methods and non-document subresource loads from any other origin get 403cross_site_request_rejected. This is what keepsPOST /api/v1/profile/deleteandGET /oauth/end_sessionfrom being triggered by a third-party page. - [x] The endpoints that hand a session to a caller who had none are checked
against the same allowlist even with no cookie on the request, because a
login-CSRF victim is signed out:
POST /api/v1/auth/login,/verify-email,/reset-password,/google,/oauth/google/callback,/auth/telegram/widget/callbackand the hostedPOST /login. Call them from an origin listed inCORS_ALLOW_ORIGINS— a signed-out call from anywhere else now gets 403, where before it went through. Server-side calls that send noOriginare unaffected.
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/logout → 200 {} 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 widget — mountForm({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:
- Without them (hosted flow): no new session is issued and the
response expires the
hub_sessioncookie — the user signs in again. - With them (you host your own reset form): a fresh session is minted
after the purge and returned alongside
{code, state}, so the caller stays signed in. Following the emailed link already proved inbox control, so a second login would be ceremony.
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
- [x] PKCE on every flow (
code_challenge_method=S256) - [x] CSRF: validate
stateon callback (constant-time compare) - [x] OIDC: validate
nonceonid_tokenclaims - [x] Token verification:
iss,aud,exp, RS256 signature via JWKS - [x]
redirect_urimatches exactly what's registered (no wildcards) - [x] Public origin built from
APP_URLenv var, never fromrequest.url/ framework helpers (which return upstream URLs behind a proxy — see Production deployment §1) - [x] App session cookie uses
__Host-prefix in production - [x]
access_tokenlives in memory, not inlocalStorage - [x]
refresh_tokenonly as cookie set by Hub ID; never read it from JS - [x] Don't log access tokens or auth codes — they grant account access
Common mistakes
- 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.
- Hard-coding
/oauth/token. Always read the discovery document. - Trusting the
id_tokenwithout signature verification. A self-signed JWT with the right claim shape will passjwt.decodeif you skipalgorithms=['RS256']andkey=.... - Storing access_token in localStorage. XSS = full account takeover. Keep it in a closure / runtime variable; let the refresh cookie do the persistence.
- Asymmetric Sign in / Sign up buttons. See Step 2.
- Calling
/oauth/revokeand assuming logout is global. It's local; the Hub ID session athubid.iosurvives. Use/oauth/end_sessionfor RP-Initiated logout — see Step 7 Pattern B. It ends that browser's session and its products, not every device: that islogout-everywhere. - Building
redirect_urifromrequest.urlin production. Behind a reverse proxy this is the upstream bind, not your public origin — token exchange fails withinvalid_grant. UseAPP_URLenv. See Production deployment §1. - 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. - Sharing one client row across environments.
app_base_urlis 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:
- Fetch
/.well-known/openid-configurationfor endpoints. - Fetch
/llms-full.txtfor narrative + code samples. - Run the snippets in Step 3–6 with the developer's actual
client_idandredirect_uri. - 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
- Internal Slack:
#hubid - Issue tracker:
https://github.com/zencreator/hubid/issues - Maintainer:
[email protected]
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:
- Revokes that client's refresh-token family and clears the
refresh_tokencookie. - 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 untilexp, so pair this with your own session teardown (see back-channel logout below). - Sends back-channel logout to your
backchannel_logout_uri, if registered. Thelogout_tokencarriessidonly when a browser session ended, and afrontend_domainclaim (fromOrigin) so one backend serving several brands can scope which of its own sessions to end. - Destroys the
hub_session(when the request carries the cookie), so/oauth/authorize?prompt=noneanswerslogin_requiredinstead of quietly handing out a new token.
Deliberate boundaries:
- Other products keep working. The cut-off is per client, so the same
user's token for another RP is untouched. This is the local sign-out the
contract promises; "sign out everywhere" is
/api/v1/profile/logout-everywhere. - All devices of your product are signed out, not just the one that
called: neither the refresh family nor the cut-off can tell devices apart
(an access token carries no session id). A different browser whose own
hub_sessionis still alive can immediately obtain new tokens throughprompt=none— the cut-off stops the tokens already issued, it does not block a fresh sign-in. - MCP (multi-audience) and Telegram-WebApp tokens are not covered. They name a resource server rather than a product, so a product sign-out has no cut-off that could apply to them; they expire on their own TTL.
- A request that names no product ends the hub session and notifies the RPs
that used it, and revokes nothing: without a product there is no refresh
family to drop and no cut-off to write. Your own tokens keep working until
they expire — send
client_id(or a bearer token) to have them closed too. Hub ID's own sign-out button is exactly this case. "Sign out of everything" is a deliberate request atPOST /api/v1/profile/logout-everywhere, never a side effect of a field left out. POST /api/v1/profile/logout-everywhereis the hub-wide one: it revokes every refresh token of that user, records a hub-wide cut-off and destroys every session./oauth/end_sessionis narrower — it ends the calling browser's session and the products that session opened, and leaves other devices alone.- If Redis is unavailable the sign-out answers
503 logout_failed: retry it. A200that leaves the token alive is the defect this replaced. - Hub ID's own sign-out page (
POST /logout, browser form) answers302to/loginon success and an HTML page with503+Retry-Afterwhen the sign-out could not be finished — the same refusal the JSON paths give, in the shape a browser can render.