# Auth pools

Each project can have one pool. End users sign up and log in, Harakumo stores them (passwords hashed with PBKDF2) and returns a signed token your app checks on each request.

Enabling a pool returns three values. The client secret (`as_…`) lets your server act for this pool only; it is shown once and can be rotated. The JWT secret (`js_…`) verifies tokens; developers can show it again. The client id (`ap_…`) is publishable and keys the browser endpoints.

For an iOS or Android app end to end (the client id in the app, the token in the Keychain or Keystore, the function that checks it), see Use Harakumo from a phone app.

## In plain words

Sign-up and log-in for the people who use your app, with email and password, email checks and password resets, without building any of it yourself.

For example: Members of a gym make an account and see only their own classes.

## Two ways to call it

| From | Endpoint | Credential |
| --- | --- | --- |
| Your server | `POST /api/auth-pools/:poolId` with an `action` | Bearer client secret (as_…), or an API key |
| A browser or mobile app | `POST /api/auth-pools/public/:clientId/:action` | None — the client id is in the URL. Off until you turn it on. |

## Enable a pool

**curl**

```bash
curl -X POST https://harakumo.com/api/projects/<projectId>/auth \
  -H "Authorization: Bearer $HARAKUMO_API_KEY"
# 201 → { "pool": {…}, "clientId": "ap_…", "clientSecret": "as_…", "jwtSecret": "js_…",
#         "serverEndpoint", "browserEndpoint", "verifyEndpoint", "algorithm": "HS256" }
```

> Store clientSecret and jwtSecret as environment variables of the project (HARAKUMO_AUTH_CLIENT_SECRET and HARAKUMO_AUTH_JWT_SECRET, the names setup_app_backend uses), with the pool id, client id and issuer (HARAKUMO_AUTH_POOL_ID, HARAKUMO_AUTH_CLIENT_ID, HARAKUMO_AUTH_ISSUER). The issuer is `https://harakumo.com/auth/<project-slug>`. An AI connector is never answered the two secrets: they are stored as HARAKUMO_AUTH_CLIENT_SECRET and HARAKUMO_AUTH_JWT_SECRET (production) in the same write as the pool, and the answer carries only those names, in `secretsStoredAs`.

## Sign-up and login from your server

Every call names its action: signup, login, send-verification, verify-email, forgot-password or reset-password. Your server keeps the returned token in its own cookie and verifies it on each request. The example uses express and jose (`npm install express jose`).

Sign-ups your server makes are limited to 600 an hour per pool, counted apart from the browser's, so sign-ups from the browser never use them up. Pass the person's address as `ip` (in Express, `req.ip`) and that address is held to the same 10 sign-ups an hour as the browser, counted together with its browser sign-ups; without `ip`, only the per-pool limit applies. Past either, 429 `rate_limited` with Retry-After. An `ip` that is not one IPv4 or IPv6 address answers 400 `invalid_ip`.

**Express**

```js
import express from 'express';
import { jwtVerify } from 'jose';

const POOL = 'https://harakumo.com/api/auth-pools/' + process.env.HARAKUMO_AUTH_POOL_ID;
const secret = new TextEncoder().encode(process.env.HARAKUMO_AUTH_JWT_SECRET);

async function pool(body) {
  const r = await fetch(POOL, {
    method: 'POST',
    headers: { authorization: 'Bearer ' + process.env.HARAKUMO_AUTH_CLIENT_SECRET, 'content-type': 'application/json' },
    body: JSON.stringify(body),
  });
  const data = await r.json();
  if (!r.ok) throw Object.assign(new Error(data.error), { status: r.status });
  return data;   // { user, token, expiresIn }
}

const app = express();
app.use(express.json());

app.post('/signup', async (req, res) => {
  try {
    // ip: the visitor's address, for the per-address sign-up limit.
    const { token } = await pool({ action: 'signup', email: req.body.email, password: req.body.password, ip: req.ip });
    res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 864e5 }).json({ ok: true });
  } catch (err) {
    res.status(err.status || 500).json({ error: err.message });   // 409 when the email already has an account
  }
});

app.post('/login', async (req, res) => {
  try {
    const { token } = await pool({ action: 'login', email: req.body.email, password: req.body.password });
    res.cookie('session', token, { httpOnly: true, secure: true, sameSite: 'lax', maxAge: 7 * 864e5 }).json({ ok: true });
  } catch (err) {
    res.status(err.status === 429 ? 429 : 401).json({ error: err.message });
  }
});

// Verify locally on every request: no call to Harakumo.
async function requireUser(req, res, next) {
  const token = (req.headers.cookie || '').match(/(?:^|; )session=([^;]+)/)?.[1];
  try {
    const { payload } = await jwtVerify(token || '', secret, {
      algorithms: ['HS256'], issuer: process.env.HARAKUMO_AUTH_ISSUER, audience: process.env.HARAKUMO_AUTH_CLIENT_ID,
    });
    req.user = payload;   // sub, email, name, email_verified …
    next();
  } catch {
    res.status(401).json({ error: 'Sign in first' });
  }
}

app.get('/me', requireUser, (req, res) => res.json({ id: req.user.sub, email: req.user.email }));
app.listen(process.env.PORT || 3000);
```

## Token claims

| Claim | Value |
| --- | --- |
| iss | The pool's issuer, `https://harakumo.com/auth/<project-slug>` |
| aud | The pool's client id |
| sub | The end user's id |
| email, name | As signed up |
| email_verified | true once they entered a verification code |
| pool | The pool id |
| tv | Token version: moves on when the user is reset or signed out everywhere |
| sid | Only on a token issued with a refresh token: the device session it belongs to |
| iat, exp | Issued and expiry; tokens last 7 days (HS256) unless you set a shorter `tokenTtl` (5 minutes to 7 days) |

> Local verification cannot see a revocation until the token expires. `POST /api/auth-pools/:id/verify { token }` also refuses tokens of deleted users, users signed out everywhere and devices signed out (reason "revoked"); call it where that matters, or shorten `tokenTtl` once your app refreshes.

## Sign in with Apple and Google

List your app's ids on the pool: `PATCH /api/auth-pools/:id/settings { appleClientIds: ["com.example.app"], googleClientIds: ["1234-abc.apps.googleusercontent.com"] }` (the iOS bundle id, and a website's Services ID, for Apple; the OAuth client ids for iOS, Android and web for Google). An empty list turns a provider off. An iOS app that offers Google sign-in must offer Sign in with Apple too.

The app signs the person in with Apple or Google on the device, then sends what it got: `sign-in-with { provider: "apple" | "google", idToken, nonce?, name? }`. The token must be signed by the provider, unexpired and made for one of your ids (401 `invalid_id_token`, `wrong_audience`, `id_token_expired`); with `nonce`, the raw nonce the app used, the token must carry it, as is or as its SHA-256 (401 `nonce_mismatch`). Apple gives the person's name only on their first sign-in, and only to the app: send it as `name`.

An Apple or Google account seen before signs in as its user. Otherwise one with a verified address is linked to the user who has that address; if that user never verified it, whoever made the account may not own the address, so its password is removed and its other sign-ins end. Otherwise a user is made with no password (201, `newUser: true`), from a browser or phone only when sign-up is open. Answers are the same as login, refresh tokens included.

Apple's private relay addresses (…@privaterelay.appleid.com) are real addresses, but mail reaches them only from a domain you register with Apple: send from your own domain (Mail) and register it in your Apple developer account. Deleting an account here does not revoke the app's Sign in with Apple at Apple; do that from your server with your own Apple key.

## Keep people signed in: refresh tokens

Add `refresh: true` to any sign-in (signup, login, verify-email, reset-password), with an optional `device` label such as "Sam's iPhone", and the answer also carries `refreshToken` (`hkrt_…`) and `refreshExpiresIn`. Keep the refresh token in the Keychain or Keystore, never where scripts can read it.

When the token is about to expire, send `refresh { refreshToken }`: the answer is a new token and a new refresh token, which replaces the old one. A session ends after 60 days without a refresh, and a year after sign-in however often it is used; then the person signs in again.

The refresh token you just replaced still works for one minute, so a refresh whose answer was lost on a bad network can be sent again. Any earlier one ends the session (401 `refresh_token_reused`): it can only be a copy. Refresh once at a time on each device (the SDK's `HarakumoAuth.refresh` does). An ended or expired session answers 401 `invalid_refresh_token`.

`logout { refreshToken }` signs out one device; its tokens are then refused by the session check and the verify call. `logout { everywhere: true }` with the user's token signs out every device. A password change or reset and Sign out everywhere in the dashboard end every session too. A user keeps at most 20 sessions; signing in on one more ends the one used longest ago.

Refresh tokens work the same on every plan and need nothing turned on. Apps that do not ask for one are unchanged.

**Phone app**

```js
import { HarakumoAuth } from '@harakumo/sdk';
const auth = new HarakumoAuth({ clientId: 'ap_…' });   // the pool's client id: publishable

const { token, refreshToken } = await auth.login({ email, password, refresh: true, device: 'Sam's iPhone' });
// … later, before the token runs out:
const next = await auth.refresh(refreshToken);   // keep next.token and next.refreshToken
await auth.logout({ refreshToken: next.refreshToken });
```

## People manage their own account

Both app stores require that people can change and delete their account inside the app. Each action takes the user's own token: `Authorization: Bearer <token>` at the public endpoint, or `token` in the body from your server.

| Action | Body | What it does |
| --- | --- | --- |
| `update-profile` | `{ name }` | Changes the name (up to 100 characters) and answers a fresh token carrying it. |
| `change-password` | `{ currentPassword, newPassword }` | Signs every other device out and answers new tokens for this one. A wrong current password counts toward the lockout (403 `wrong_password`). |
| `change-email` | `{ newEmail, password }` | Emails a code to the new address. Nothing changes until it comes back. |
| `confirm-email-change` | `{ code }` | Moves the account to the new address, marks it verified, signs other devices out and tells the old address. 409 `email_taken` if another account has the address. |
| `sessions` | — | The signed-in devices: `{ sessions: [{ id, device, created_at, last_used_at, current }] }`. |
| `revoke-session` | `{ sessionId }` | Signs one of those devices out. |
| `delete-account` | `{ password }` or `{ code }` | Deletes the account at once, with its codes and sessions. It cannot be undone. |
| `send-deletion-code` | — | For an account without a password: emails the code `delete-account` takes. |

> Codes for these actions last 15 minutes, allow 5 tries and are limited to 5 an hour per address and per account. From the browser they also count toward the public code limits below. 401 `token_required` when no token is sent, `not_signed_in` when it is expired or signed out.

## Account deletion without the app

Google Play also asks for a web link where people can ask for their account to be deleted without the app. Turn on the hosted page with `PATCH /api/auth-pools/:id/settings { deletionPage: true }` (or on the pool's Auth page), and say what your app keeps after deleting an account, and for how long, with `deletionNote` (plain text, up to 1,000 characters).

The page is `https://harakumo.com/account/delete/<clientId>`. Someone enters their email address, gets a code, and entering it deletes the account. It works while browser sign-in is off, and answers the same whether or not an address has an account.

A deletion page of your own runs the same two actions through your server or the public endpoint: `request-deletion { email }`, then `confirm-deletion { email, code }`.

## Push notifications

Notifications go straight to Apple's push service (APNs) and to Firebase Cloud Messaging, with your own keys. Save them once: `PUT /api/push/:poolId/credentials { service: "apns", keyId, teamId, bundleId, privateKey }` with the .p8 file's text, and `{ service: "fcm", serviceAccount }` with the Firebase service account JSON. Each key is checked before it is kept, sealed, and never shown again.

After sign-in, the app registers the device's token for the user: `register-push { pushToken, platform: "ios" | "android" }` with the user's token (`service: "fcm"` for an iOS app that uses Firebase; `environment: "sandbox"` for an APNs development build). On sign-out it calls `unregister-push { pushToken }`. A token belongs to one user at a time: registered by someone else, it moves to them. A user keeps their 20 newest devices.

Your server sends: `POST /api/push/:poolId/send { userIds, title, body, data? }` with the client secret or an API key. Up to 100 users and 500 devices a call, 600 calls an hour per pool (429 `rate_limited`). The answer lists each device: a device the service reports as gone is switched off and not sent to again; `no_credentials` and `credentials_refused` mean a key is missing or was refused. `data` is text values only, 3,000 bytes at most: put no personal details in a notification, since lock screens are public.

Not yet: topics and sending to every user at once, scheduled sends, delivery receipts, and categories that users switch off one by one.

## Hear when a user is deleted

Set an https endpoint with `PUT /api/auth-pools/:id/webhook { url }`. The first time, the answer carries the signing secret once (`hkwhsec_…`); store it in your server's environment. An AI connector is never answered it: it is stored as an environment variable of the project (production), and the answer names that variable in `secretsStoredAs`.

When a user is deleted (by themselves, on a deletion page, or in the dashboard), your endpoint gets a POST: `{ id, type: "user.deleted", created, data: { pool_id, project_id, user: { id, email }, reason } }`, with `reason` one of `self`, `deletion_page` or `dashboard`. Check the `Harakumo-Signature` header with `Harakumo.verifyWebhook(secret, rawBody, header)`, the same check as payment events, and delete what your app keeps about that user. The id (also the `Harakumo-Delivery` header) is the same on every retry.

Answer with any 2xx. A delivery that fails is tried again with growing gaps, up to 8 tries over about two days. A retry that has fallen due goes out when one of your users signs in or acts on their account (at most once a minute), and when you open the webhook settings. `POST …/webhook { action: "test" }` sends a `ping`; `{ action: "rotate-secret" }` replaces the secret; `DELETE` removes the webhook.

## Sign-in from the browser or a mobile app

1. Turn browser access on: `PATCH /api/auth-pools/:id/settings { publicEnabled: true }`. Letting anyone create an account is a separate switch, `publicSignup: true`.
2. The project's own addresses and verified custom domains are allowed automatically. Add other origins with `allowedOrigins` (up to 20): https addresses, http only for localhost, and for a website wrapped as a phone or desktop app (Capacitor, Ionic, Cordova, Tauri) exactly `capacitor://localhost`, `ionic://localhost`, `app://localhost` or `tauri://localhost`. None of those app origins is allowed until you add it, and no other custom scheme can be. A request from any other Origin is refused (403 `origin_not_allowed`) before anything runs, and the answer says which origin to add, or why it cannot be added. A native app, or a wrapped app that makes its calls through native HTTP (such as CapacitorHttp), sends no Origin and needs no entry. Android app shells use `https://localhost`, an ordinary origin you add like any other.
3. Call the public endpoints with the client id. `GET …/session` with `Authorization: Bearer <token>` answers who is signed in.

**Browser**

```js
const AUTH = 'https://harakumo.com/api/auth-pools/public/ap_…';

const r = await fetch(AUTH + '/login', {
  method: 'POST', headers: { 'content-type': 'application/json' },
  body: JSON.stringify({ email, password }),
});
const { token, user, error } = await r.json();

const me = await (await fetch(AUTH + '/session', { headers: { authorization: 'Bearer ' + token } })).json();
```

> Browser limits: sign-up 10 per IP an hour and 600 per pool an hour; login 30 per IP per 10 minutes; codes 20 per IP an hour, and 50 an hour and 300 a day per pool (see below). An IPv6 address counts by its /64, so moving between addresses in one network does not reset these.

## A bot check on sign-up

Not available yet: until Harakumo turns the check on, `PATCH /api/auth-pools/:id/settings { botCheck: true }` answers 409 `bot_check_unavailable`, and `settings` says `botCheck.available: false`.

Once it is, and you turn it on, a sign-up from the browser endpoint must carry `botToken`, a short check passed on one of the pool's own pages (its project's addresses and allowed origins): 400 `bot_check_required` without one, 403 `bot_check_failed` for one that was not passed, was used before or came from another site. Show the check with the loader in `botCheck.loaderUrl`: `harakumoBotCheck.render('#check', { action: 'signup', onToken })`. A native app has no page to show it on, so leave it off for an app that signs people up natively, or sign them up from your server.

Your own forms can use it too: show the check with the same loader and check the token from your server with `POST /api/auth-pools/:id/bot-check { token, ip?, action? }` (client secret or API key), which answers `{ ok: true, hostname, action }` or `{ ok: false, code, error }`. A token works once, for five minutes.

## Email verification and password reset

1. `send-verification { email }` emails a 6-digit code; `verify-email { email, code }` marks the address verified and returns a fresh token.
2. `forgot-password { email }` emails a reset code; `reset-password { email, code, password }` sets the new password, signs the user out everywhere else and returns a token.

- Codes last 15 minutes and allow 5 tries; at most 5 codes an hour per address.
- They are sent under your app's sender name and count toward the workspace's email quota. If the app has no sender name recipients may see (see Mail → Sender names), code requests answer 409 `mail_not_ready`.
- Browser answers never reveal whether an email has an account.

## Limits on codes asked for from the browser

Anyone who has your client id can ask the public endpoints to email a code, so those codes have limits of their own, and a stranger cannot spend the mail your app needs for its own password resets:

- Per pool: at most 50 codes an hour and 300 a day. Only codes actually mailed count: a request for an address with no account sends nothing and uses none.
- Per workspace: codes asked for from the browser, across all its pools, may use at most 20% of the 30-day email quota (20 on hobby, 400 on pro, 2,000 on team, 20,000 on scale). They still count toward the quota like any email.
- Past either limit, `send-verification` and `forgot-password` answer 429 with code `code_limit` and a Retry-After header, whatever the address, so the answer reveals nothing about who has an account. The per-IP and per-address limits answer 429 `rate_limited`.
- Codes your server asks for with the client secret (or an API key) are not held to these limits, only to the 5 an hour per address, so resets your own server sends keep working.

## Managing users and secrets

- `GET /api/auth-pools/:id?q=&before=&limit=` lists users (up to 200 a page).
- `PATCH /api/auth-pools/:id/users/:userId` with action reset-password (with password), send-password-reset, revoke-sessions or unlock. `DELETE` removes the user; their tokens and refresh tokens stop working, and your webhook hears `user.deleted` (reason `dashboard`).
- `GET /api/auth-pools/:id/secret` shows the JWT secret again (developer and up, audited). An AI connector's token is not shown it: it gets 403 `dashboard_only`, with `dashboard_url` the pool's page in the dashboard.
- `POST /api/auth-pools/:id/secret { which: "jwt" }` rotates it, keeping the old one valid for 24 hours (or `keepPrevious: false` to revoke at once). `{ which: "client" }` replaces the client secret immediately. Rotating needs an admin.
- When an AI connector rotates a secret, the new value is stored as the project's environment variable (HARAKUMO_AUTH_JWT_SECRET or HARAKUMO_AUTH_CLIENT_SECRET, production) in the same write, and the answer carries only that name, in `secretsStoredAs`. The app reads it after its next deploy; until then a rotated client secret makes its server's calls fail.

## Limits and what is not available yet

- Passwords 8–256 characters. 10 wrong passwords lock the account for 15 minutes (429 with Retry-After).
- Codes asked for from the browser: 50 an hour and 300 a day per pool, and at most 20% of the workspace's email quota (429 `code_limit`).
- Apart from Sign in with Apple and Google (above): No social login, no end-user MFA, no hosted sign-in page and no RS256/JWKS yet. The hosted page that exists is the one for deleting an account.
- Pools per plan: 1 hobby, 10 pro, 50 team, 100 scale. Users per pool: 1,000 hobby, 50,000 pro, 100,000 team, 250,000 scale; past it a sign-up, or a first Sign in with Apple or Google, answers 402 `plan_limit` and no account is made. From the browser (the client id) that answer names no plan and no numbers; your server's carries `limit` and `current`. Existing users are never removed.
- Verifying tokens locally scales with your app. Sign-ups and logins run on the shared platform and suit apps with thousands of active users.

## Reference

**SDK**

```js
// On your server, the client secret works as the SDK key — for this pool's sign-in actions only.
const pool = new Harakumo({ apiKey: process.env.HARAKUMO_AUTH_CLIENT_SECRET });
const { token } = await pool.auth.signup(poolId, { email: 'user@yourapp.com', password: 'correct-horse-9' });
const { token: t2 } = await pool.auth.login(poolId, { email: 'user@yourapp.com', password: 'correct-horse-9' });
const { valid, claims, reason } = await pool.auth.verifyToken(poolId, t2);

// Keep a phone signed in: ask for a refresh token, trade it when the token runs out.
const { refreshToken } = await pool.auth.login(poolId, { email: 'user@yourapp.com', password: 'correct-horse-9', refresh: true });
const next = await pool.auth.refresh(poolId, refreshToken);

// The user's own account, with their token:
await pool.auth.deleteAccount(poolId, next.token, { password: 'correct-horse-9' });

// Managing the pool needs an API key:
const { pool: p, clientId, clientSecret, jwtSecret } = await hk.auth.enable(project.id);
```

**REST**

```http
POST   /api/projects/:id/auth                     # enable → clientId, clientSecret, jwtSecret, endpoints (a connector: secretsStoredAs)
POST   /api/auth-pools/:id        { "action": "signup"|"login"|"send-verification"|"verify-email"|"forgot-password"|"reset-password", "email", "password"?, "code"?, "name"?, "ip"?, "refresh"?, "device"? }
POST   /api/auth-pools/:id        { "action": "sign-in-with", "provider": "apple"|"google", "idToken", "nonce"?, "name"?, "refresh"?, "device"? }
POST   /api/auth-pools/:id        { "action": "refresh", "refreshToken" } · { "action": "logout", "refreshToken" } | { "action": "logout", "token", "everywhere": true }
POST   /api/auth-pools/:id        { "action": "update-profile"|"change-password"|"change-email"|"confirm-email-change"|"sessions"|"revoke-session"|"send-deletion-code"|"delete-account", "token", … }
POST   /api/auth-pools/:id        { "action": "request-deletion", "email" } · { "action": "confirm-deletion", "email", "code" }
POST   /api/auth-pools/:id/verify { "token": "…" }   → { valid, claims, user } | { valid: false, reason }
GET    /api/auth-pools/:id/settings · PATCH { "publicEnabled"?, "publicSignup"?, "allowedOrigins"?, "deletionPage"?, "deletionNote"?, "tokenTtl"?, "botCheck"?, "appleClientIds"?, "googleClientIds"? }
POST   /api/auth-pools/:id/bot-check { "token", "ip"?, "action"? }   → { ok, hostname, action } | { ok: false, code, error }
GET    /api/auth-pools/:id/webhook · PUT { "url" } · POST { "action": "test"|"rotate-secret" } · DELETE
GET    /api/push/:poolId · PUT /api/push/:poolId/credentials { "service": "apns"|"fcm", … } · DELETE ?service=
POST   /api/push/:poolId/send { "userIds", "title"?, "body"?, "data"?, "sound"?, "badge"?, "ttlSeconds"?, "collapseKey"? }
GET    /api/auth-pools/:id/secret · POST { "which": "jwt"|"client", "keepPrevious"? }
GET    /api/auth-pools/:id?q=&before=&limit=     # users
PATCH  /api/auth-pools/:id/users/:userId         { "action": "reset-password"|"send-password-reset"|"revoke-sessions"|"unlock" }
DELETE /api/auth-pools/:id/users/:userId
DELETE /api/auth-pools/:id                       # disable: removes the pool and every user

POST   /api/auth-pools/public/:clientId/signup | login | sign-in-with | send-verification | verify-email | forgot-password | reset-password | refresh | logout
POST   /api/auth-pools/public/:clientId/update-profile | change-password | change-email | confirm-email-change | sessions | revoke-session | register-push | unregister-push | send-deletion-code | delete-account
       Authorization: Bearer <token>
POST   /api/auth-pools/public/:clientId/request-deletion | confirm-deletion
GET    /api/auth-pools/public/:clientId/session  Authorization: Bearer <token>
GET    /api/auth-pools/public/:clientId/bot-check  # the bot check's loader script
```
