Documentation

Humad is a World ID–powered identity layer for any web app. Two products live here:Login with Humad (OIDC) for full sign-in flows, and a Verify APIfor one-shot human-verification checks. Pick the section that matches what you're building.

Quick start

A. Login with Humad (recommended for SaaS / web apps)

Adds a "Sign in" button to your app. Users authenticate with World ID, you get a stable sub, handle, and verified-human status. Standards-compliant OAuth 2.1 / OIDC.

  1. 1. Register an OAuth client in the dashboard to get client_id and client_secret.
  2. 2. npm install humad-sdk
  3. 3. Wire up signIn() and handleCallback() (see below).

Live demo: humad-demo.vercel.app

B. Verify API (one-shot human check)

Replace CAPTCHA. No session, no users — just "is this a human?" with a managed nullifier dedup store.

npm install humad-sdk @worldcoin/idkit
import { HumanAuth } from "humad-sdk/react";

function VerifyButton() {
  return (
    <HumanAuth
      appId="app_your_world_id"
      apiKey="ha_your_key"
      action="login"
      onVerified={(r) => console.log(r.nullifier_hash, r.is_new_user)}
      onError={(e) => console.error(e)}
    />
  );
}

The React component is still exported as HumanAuth for v0.2.x compatibility; it will be renamed to Humad in v1.0.

Login with Humad (OIDC)

Standards-compliant OAuth 2.1 + OpenID Connect. PKCE-required for public clients, client_secret_post / client_secret_basic for confidential clients, RS256-signed id_tokens, refresh token rotation, RP-Initiated Logout.

1. Register an OAuth client

Go to /dashboard/oauthand create a client. You'll need at minimum a name and one redirect_uri. For SPAs, also set post_logout_redirect_uris.

  • Public client (SPA / mobile): no client_secret; PKCE is enforced.
  • Confidential client (server-side): store client_secret in your backend; PKCE still recommended.
  • Scopes: openid (required), profile, verified_human, email, offline_access (for refresh tokens).

You can later edita client's name / homepage / redirect_uris / scopes from the dashboard (the client_id and client_type stay frozen). Confidential clients also expose a Rotate secret button — handy when a secret leaks; the old secret stops working immediately, so coordinate with your RP deploy.

2. Browser flow

import { signIn, handleCallback, getUser } from "humad-sdk";

// On your "Login with Humad" button:
await signIn({
  clientId: "ha_oauth_xxxxxxxx",
  redirectUri: "https://yourapp.com/oauth/callback",
  scopes: ["openid", "profile", "verified_human"],
});

// On /oauth/callback:
const { tokens } = await handleCallback({ clientId: "ha_oauth_xxxxxxxx" });
const user = await getUser(tokens);

// user.sub                  — stable, pairwise user id (string)
// user.handle               — Humad handle (e.g. "alice")
// user.verified_human       — boolean
// user.verification_level   — "orb" | "device" | "phone"

3. Server flow (confidential client)

import { exchangeCodeForTokens, getUserInfo } from "humad-sdk";

// In your /oauth/callback route handler:
const tokens = await exchangeCodeForTokens({
  clientId: process.env.HUMAD_CLIENT_ID!,
  clientSecret: process.env.HUMAD_CLIENT_SECRET!,
  code,
  codeVerifier,
  redirectUri,
});

const user = await getUserInfo({ accessToken: tokens.accessToken });

Silent refresh

Access tokens expire in 15 minutes. Pick the helper that matches your security posture.

(a) Background timer — if you can store the refresh_token

import { startAutoRefresh } from "humad-sdk";

const controller = startAutoRefresh({
  clientId: "ha_oauth_xxxxxxxx",
  initialTokens: tokens,
  onUpdate: (next) => setTokens(next),   // persist the rotated tokens
  onError: (err) => console.warn(err),   // refresh failed — fall back to signIn()
  refreshLeewaySec: 60,                  // renew 60s before exp
});

// later (sign out / unmount):
controller.stop();

(b) iframe + prompt=none — if you can't safely hold a refresh_token

import { silentRenew, handleSilentCallback } from "humad-sdk";

try {
  const { tokens: next } = await silentRenew({
    clientId: "ha_oauth_xxxxxxxx",
    redirectUri: "https://yourapp.com/oauth/callback",
  });
  setTokens(next);
} catch (err) {
  // SSO expired or interaction required — fall back to signIn()
}

// On /oauth/callback, handle BOTH normal and silent iframe results:
const wasSilent = await handleSilentCallback({ clientId });
if (!wasSilent) {
  const { tokens } = await handleCallback({ clientId });
  // ... normal post-login flow
}

Silent renew relies on the Humad SSO cookie. If the user has been inactive long enough for the SSO session to expire, the iframe will time out and you must initiate a regular signIn().

Logout

Two flavors, depending on whether you want to keep the Humad SSO session alive across other RPs.

(a) Quiet token revocation — default

Invalidates the user's tokens for your app. The Humad SSO session continues, so the user stays signed in to other RPs.

import { signOut } from "humad-sdk";

await signOut({
  token: tokens.refreshToken!,
  tokenTypeHint: "refresh_token",
  clientId: "ha_oauth_xxxxxxxx",
});

(b) RP-Initiated Logout — end the SSO session

Ends the user's Humad SSO session and redirects back to your post-logout page. Use this for a real "sign out everywhere" UX. Requires post_logout_redirect_uri to be pre-registered on the client.

await signOut({
  mode: "navigate",
  clientId: "ha_oauth_xxxxxxxx",
  idTokenHint: tokens.idToken,
  postLogoutRedirectUri: "https://yourapp.com/goodbye",
  state: "csrf-token-or-return-marker",
});

Need a URL only (e.g. for an <a href>)? Use buildEndSessionUrl(...). The endpoint follows OIDC RP-Initiated Logout 1.0.

Verify API

Direct REST access if you don't want to use the SDK. Use this when you only need one-shot human verification (CAPTCHA-replacement) and don't need user sessions.

POST/api/verify

Verify a World ID proof. Handles Cloud API verification, nullifier dedup, and MAU tracking.

Headers

x-humanauth-key: ha_your_api_key

Request body

{
  "proof": "0x...",
  "merkle_root": "0x...",
  "nullifier_hash": "0x...",
  "action": "login",
  "verification_level": "orb"   // optional
}

Response

{
  "success": true,
  "nullifier_hash": "0x...",
  "is_new_user": true,
  "action": "login"
}
POST/api/rp-context

Generate a signed RP context for World ID 4.0 verification. Required for IDKit v4.

Request body

{ "action": "login" }   // optional, defaults to "humanauth-verify"

Response

{
  "rp_context": {
    "rp_id": "app_xxx",
    "nonce": "...",
    "created_at": 1234567890,
    "expires_at": 1234567890,
    "signature": "0x..."
  }
}

Apps API

CRUD/api/apps

App management endpoints. Requires dashboard JWT authentication.

  • GET /api/apps — list your apps
  • POST /api/apps — register a new app
  • GET /api/apps/:id — app details + recent logs
  • DELETE /api/apps/:id — delete (cascades keys, logs, nullifiers)
  • GET /api/apps/:id/keys — list API keys
  • POST /api/apps/:id/keys — create new API key
  • DELETE /api/apps/:id/keys — revoke a key (body: {"key_id": "..."})

OIDC discovery

Most OIDC client libraries can configure themselves from the discovery document.

https://humanauth.vercel.app/.well-known/openid-configuration

Note on domains: the IdP issuer currently lives at humanauth.vercel.app as a placeholder. A humad.*custom domain will be assigned later. The SDK reads the issuer URL from discovery, so SDK consumers won't see a breaking change. If you hardcode issuer in your config, plan a one-line update.

Concepts

Nullifier hash

A unique, anonymous identifier per (user, action). The same person verifying "login" and "vote" produces different nullifiers — privacy by design. Humad stores and deduplicates these automatically. For OIDC, the sub claim is a pairwise identifier derived from the user's World ID nullifier and the RP's client_id.

RP signing (World ID 4.0)

World ID 4.0 requires all verification requests to be signed by the Relying Party. Humad manages your signing keys (encrypted at rest with AES-256-GCM) and generates signed RP contexts on demand.

MAU tracking

Monthly Active Users are counted by unique nullifier per calendar month. Your dashboard shows real-time MAU counts and historical trends.

SSO session

Humad maintains a session cookie on the IdP domain so a user signing in to one RP can sign in to other RPs without re-prompting World ID. Silent refresh and prompt=none rely on this. RP-Initiated Logout ends the SSO session for all RPs at once.

Operational notes

Signing-key rotation

Humad signs id_tokens with RS256 keys exposed at /.well-known/jwks.json. We support overlap-style rotation: the new key publishes alongside the previous key for a grace period so already-issued id_tokens remain verifiable. RPs that fetch JWKS dynamically (the SDK does this) need no action.

Run tsx scripts/rotate-oidc-keys.ts to generate a fresh keypair; promote it via the OIDC_* / OIDC_PREV_* environment variables. Only the Humad operator can do this.

Token lifetimes (current defaults)

  • access_token: 15 minutes (opaque, server-introspected)
  • id_token: 15 minutes (RS256 JWT)
  • refresh_token: 30 days, rotated on every use
  • authorization code: 60 seconds, one-time use

Health check

GET /api/health returns 200 with a JSON body when the IdP is healthy (DB reachable, OIDC signing key loaded), 503 otherwise. Suitable for UptimeRobot / Vercel Cron / your own monitor.

{
  "ok": true,
  "service": "humanauth",
  "env": "production",
  "sha": "ab12cdef",
  "durationMs": 42,
  "checks": {
    "db":   { "ok": true, "durationMs": 38 },
    "jwks": { "ok": true, "durationMs": 4  }
  }
}

Status

Beta. Issuer is humanauth.vercel.app until the humad.* domain is assigned. Production RPs are welcome — give us a heads up before launch so we can keep your client_id pinned through any migrations.