Hub ID — One ID everywhere

Hub ID — Developer Integration Guide

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

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

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


TL;DR

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

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


OIDC discovery

All endpoints are advertised at:

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

Use these dynamically — never hard-code endpoint paths.

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

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


Step 1 — Register your client

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

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

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

The rule: buttons must be symmetric.

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

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

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

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

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

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

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

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

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

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

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

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

Why this is wrong:

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

screen_hint reference

Value Effect
screen_hint=login Land on /login first (this is also the default)
screen_hint=signup Land on /register first (with Sign In link to login)
screen_hint=google Skip Hub ID's login form, go straight to Google's OAuth (/auth/google/start). Used by HubID.signInWithGoogle().
(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:

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

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

What /oauth/end_session does:

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

What neither pattern does


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:

Why this widget pattern:

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

The aihub recipe (Next.js)

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

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

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

/api/auth/begin

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

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

/api/auth/exchange

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

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

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

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

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

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

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

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

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

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

Why returning code (not tokens) from /login

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

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

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

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

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

Security checklist (embedded-specific)

Failure modes the SDK handles

Scenario What the user sees
Wrong password "Email or password is incorrect." (constant-time)
Email already registered (signup) "An account with this email already exists. Try signing in."
Weak password (signup, < 8 chars) "Password must be at least 8 characters."
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:


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


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
/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