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(). |
| (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.
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 neither pattern does
- Neither invalidates access_tokens already in flight; they expire after their TTL (15 min default).
- Pattern A does not terminate the
hub_sessioncookie at hubid.io. - Pattern B does not revoke refresh tokens of other RPs the user is signed in to. If they were doing silent-SSO via a still-valid refresh cookie, that other RP keeps working until its refresh expires.
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 — server-side allowlist
If you use the embedded SDK (mountForm / mountAccount), the JSON
API at /api/v1/auth/* enforces an Origin header allowlist in
addition to CORS. Your prod origin must be added to Hub ID's
EMBEDDED_AUTH_ORIGINS env var, or every state-changing call (login,
logout) returns 403 origin_not_allowed — the widget will look like
it works for sign-in (which is a top-level redirect) and silently
fail for sign-out.
# Hub ID's /opt/hubid/.env
EMBEDDED_AUTH_ORIGINS=["https://your-app.com","https://hubid.io",...]
CORS_ORIGINS=["https://your-app.com","https://hubid.io",...]
After editing, restart Hub ID. (Both lists are JSON arrays parsed by pydantic-settings; missing or comma-separated forms blow up at boot.)
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 | hub_session cookie set as SameSite=Lax — see Hub ID-side fix in Pattern A; or RP origin missing from CORS_ORIGINS so the cross-origin /session XHR fails |
| Sign-out flickers and comes back logged in | RP origin missing from EMBEDDED_AUTH_ORIGINS → /api/v1/auth/logout returns 403 → cookie not cleared |
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?}): Promise<{code, state, user_sub}>;
HubID.signInWithGoogle(): 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 (backend not shipped)
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 require Origin header from the
client's allowlisted origin (embedded_auth_origins setting on Hub ID).
All return JSON, never HTML. State-changing endpoints set the
hub_session cookie on the hubid.io apex (SameSite=None; Secure; Partitioned in prod, SameSite=Lax in localhost dev).
| Path | Method | Body | 200 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 | same + display_name? |
{code, state, user_sub} + Set-Cookie hub_session |
/check-email |
POST | {email} |
{available: bool} (rate-limited 30/min/IP) |
/logout |
POST | hub_session cookie | {} + cookie cleared |
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]
embedded_auth_originssetting on Hub ID is an explicit allowlist (no*) - [x] Every embedded endpoint requires
Originheader match (extra defence over CORS) - [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 - [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.
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." |
| 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 |
Sign-in methods supported today
| Method | SDK API | Status |
|---|---|---|
| Email + password | signIn, signUp, mountForm({mode}) |
✅ Shipped |
signInWithGoogle(), mountForm({showGoogle: true}) (default on) |
✅ Shipped — full-page redirect via screen_hint=google |
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
Two SDK options are flagged off by default until the matching backend ships. RPs can flip them on once the endpoints exist, but for now opting in early gives you placeholder UI that doesn't work yet.
| Option | Backend it needs | Status |
|---|---|---|
showMagicLink: true |
POST /api/v1/auth/magic-link/request + email infrastructure (SES/SendGrid/SMTP — Hub ID currently has no transactional-email plumbing) + a /m/{code} consumer that issues a PKCE-bound auth code instead of just a session |
Hub ID has device-to-device magic link (signed-in user generates a URL); the email-based passwordless flow is the missing piece. |
showPasswordReset: true |
POST /api/v1/auth/password-reset/{request,confirm} + email + a reset-token consumer page on Hub ID |
Not started. |
Until those land, use:
- Forgot password: the user signs into their Hub ID profile via the redirect flow, then changes their password from there. Less convenient but no infra required.
- Magic link: not available — passwords (or Google) are the only paths until the email pipeline is built.
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}`;
}
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 global (RP-Initiated) logout — see Step 7 Pattern B. - 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 prod origin to
EMBEDDED_AUTH_ORIGINS. Sign-in keeps working (top-level redirect), sign-out silently 403s. See Production deployment §3.
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]