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.
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 -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 (for example HK_CLIENT_SECRET and HK_JWT_SECRET), with the pool id, client id and issuer. The issuer is https://harakumo.com/auth/<project-slug>.
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).
import express from 'express';
import { jwtVerify } from 'jose';
const POOL = 'https://harakumo.com/api/auth-pools/' + process.env.HK_POOL_ID;
const secret = new TextEncoder().encode(process.env.HK_JWT_SECRET);
async function pool(body) {
const r = await fetch(POOL, {
method: 'POST',
headers: { authorization: 'Bearer ' + process.env.HK_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 {
const { token } = await pool({ action: 'signup', 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 || 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.HK_ISSUER, audience: process.env.HK_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 |
| iat, exp | Issued and expiry; tokens last 7 days (HS256) |
Local verification cannot see a revocation until the token expires. POST /api/auth-pools/:id/verify { token } also refuses tokens of deleted users and users signed out everywhere (reason "revoked"); call it where that matters.
Sign-in from the browser or a mobile app
- Turn browser access on:
PATCH /api/auth-pools/:id/settings { publicEnabled: true }. Letting anyone create an account is a separate switch,publicSignup: true. - The project's own addresses and verified custom domains are allowed automatically. Add other origins with
allowedOrigins(https only; http only for localhost; up to 20). A request from any other Origin is refused before anything runs. - Call the public endpoints with the client id.
GET …/sessionwithAuthorization: Bearer <token>answers who is signed in.
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).
Email verification and password reset
send-verification { email }emails a 6-digit code;verify-email { email, code }marks the address verified and returns a fresh token.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, 1,000 on pro, 20,000 on enterprise). They still count toward the quota like any email.
- Past either limit,
send-verificationandforgot-passwordanswer 429 with codecode_limitand 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 429rate_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/:userIdwith action reset-password (with password), send-password-reset, revoke-sessions or unlock.DELETEremoves the user; their tokens stop verifying.GET /api/auth-pools/:id/secretshows the JWT secret again (developer and up, audited).POST /api/auth-pools/:id/secret { which: "jwt" }rotates it, keeping the old one valid for 24 hours (orkeepPrevious: falseto revoke at once).{ which: "client" }replaces the client secret immediately. Rotating needs an admin.
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). - No social login, no end-user MFA, no hosted sign-in page, no refresh tokens and no RS256/JWKS yet.
- Pools per plan: 1 hobby, 10 pro, 100 enterprise; end users per pool are not capped.
- 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
// 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.HK_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);
// Managing the pool needs an API key:
const { pool: p, clientId, clientSecret, jwtSecret } = await hk.auth.enable(project.id);POST /api/projects/:id/auth # enable → clientId, clientSecret, jwtSecret, endpoints
POST /api/auth-pools/:id { "action": "signup"|"login"|"send-verification"|"verify-email"|"forgot-password"|"reset-password", "email", "password"?, "code"?, "name"? }
POST /api/auth-pools/:id/verify { "token": "…" } → { valid, claims, user } | { valid: false, reason }
GET /api/auth-pools/:id/settings · PATCH { "publicEnabled"?, "publicSignup"?, "allowedOrigins"? }
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 | send-verification | verify-email | forgot-password | reset-password
GET /api/auth-pools/public/:clientId/session Authorization: Bearer <token>