# 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`](https://hubid.io/llms.txt) and the full guide as plain Markdown at [`/llms-full.txt`](https://hubid.io/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 ```sql 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_uris` is an array — list every callback URL (including `localhost` for development). --- ## Step 2 — Sign-in / Sign-up button design (read this) This is the section that exists because [Calypso shipped a bug here](https://hubid.io/docs#anti-pattern-asymmetric-buttons) 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: ```json GET /.well-known/openid-configuration { ..., "screen_hint_values_supported": ["login", "signup", "google"] } ``` --- ## Step 3 — PKCE flow (browser) ### Generate the verifier and challenge ```javascript 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 ```javascript 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 ```javascript // 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 # 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', ) ``` ```javascript // 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): ```http GET /oauth/userinfo HTTP/1.1 Host: hubid.io Authorization: Bearer ``` Response shape depends on requested scopes: ```json { "sub": "550e8400-e29b-41d4-a716-446655440000", "name": "Alex", "picture": "https://hubid.io/avatars/...", "email": "alex@example.com", "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: ```javascript 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. ```javascript // 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: '' }), }); // 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". ```javascript // 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_session` cookie + Redis session entry at `hubid.io`. - Validates `post_logout_redirect_uri` *origin* against any of your registered `redirect_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 any `state` query 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_session` cookie 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: ```bash # /srv/your-app/.env.production APP_URL=https://your-app.com ``` ```ts 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. ```ts 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. ```bash # 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) ```html
``` That's it. The SDK auto-injects its own stylesheet (CSS is bundled into the JS file at serve time). No `` 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 ```ts 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; 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; // full-page redirect; never resolves HubID.signOut(): Promise; 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 ```ts { 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 ```ts { manageUrl?: string; // default `${issuer}/profile` placement?: 'auto' | 'up' | 'down'; // popover side; default 'auto' signedOutContent?: string | HTMLElement | null; // shown when no session onSignOut?: () => void | Promise; // 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: ```html ``` 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) and `Sign 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` ```ts 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` ```ts 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=true` in `oauth_clients` - [x] `embedded_auth_origins` setting on Hub ID is an explicit allowlist (no `*`) - [x] Every embedded endpoint requires `Origin` header match (extra defence over CORS) - [x] Rate limit per IP and per email on `/login` (sliding window, Redis) - [x] 150 ms artificial floor on `/login` response (timing-side-channel resistance) - [x] `/check-email` returns 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_events` table) - [x] PKCE verifier in httpOnly cookie, never in `localStorage` - [x] `access_token` not persisted in your app's session cookie - [x] Hub ID's `hub_session` cookie: `SameSite=None; Secure; HttpOnly` in 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 | | Google | `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: ```javascript 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 `state` on callback (constant-time compare) - [x] OIDC: validate `nonce` on `id_token` claims - [x] Token verification: `iss`, `aud`, `exp`, RS256 signature via JWKS - [x] `redirect_uri` matches exactly what's registered (no wildcards) - [x] Public origin built from `APP_URL` env var, **never** from `request.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_token` lives in memory, not in `localStorage` - [x] `refresh_token` only 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 1. **Skipping PKCE** because "we use a confidential client". PKCE is required for all clients in Hub ID — including server-side ones. The code_verifier protects against code interception independent of client_secret. 2. **Hard-coding `/oauth/token`.** Always read the discovery document. 3. **Trusting the `id_token` without signature verification.** A self-signed JWT with the right claim shape will pass `jwt.decode` if you skip `algorithms=['RS256']` and `key=...`. 4. **Storing access_token in localStorage.** XSS = full account takeover. Keep it in a closure / runtime variable; let the refresh cookie do the persistence. 5. **Asymmetric Sign in / Sign up buttons.** See Step 2. 6. **Calling `/oauth/revoke` and assuming logout is global.** It's local; the Hub ID session at `hubid.io` survives. Use `/oauth/end_session` for global (RP-Initiated) logout — see Step 7 Pattern B. 7. **Building `redirect_uri` from `request.url` in production.** Behind a reverse proxy this is the upstream bind, not your public origin — token exchange fails with `invalid_grant`. Use `APP_URL` env. See Production deployment §1. 8. **Forgetting to add 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](https://llmstxt.org) | | `/llms-full.txt` | markdown | Full guide concatenated, plain text | | `/docs/integration.md` | markdown | This document, raw | | `/docs` | html | This document, rendered | | `/.well-known/openid-configuration` | json | OIDC discovery (machine-readable) | | `/.well-known/jwks.json` | json | Public signing keys | If you are an LLM helping a developer integrate Hub ID, the typical sequence is: 1. Fetch `/.well-known/openid-configuration` for endpoints. 2. Fetch `/llms-full.txt` for narrative + code samples. 3. Run the snippets in Step 3–6 with the developer's actual `client_id` and `redirect_uri`. 4. Make sure the Sign in / Sign up button design follows Step 2 — that is the most-skipped piece. --- ## Versioning and changelog This guide is versioned with Hub ID itself. Breaking changes ship as new sections in the discovery document; non-breaking additions appear without notice. The canonical changelog is at `https://hubid.io/docs/changelog` (Phase 2+). --- ## Support - Internal Slack: `#hubid` - Issue tracker: `https://github.com/zencreator/hubid/issues` - Maintainer: `alex@zencreator.pro`