Skip to content
Docs · App services

Auth pools

Sign-in for your app's users: email and password, email verification and password reset by code, and 7-day JWTs you verify yourself. From your server, or from the browser with a publishable client id.

View as Markdown
All topics
On this page

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.

useryour appauth poolloginverifyJWT ✓ hs256drop-in auth for your users

Two ways to call it

FromEndpointCredential
Your serverPOST /api/auth-pools/:poolId with an actionBearer client secret (as_…), or an API key
A browser or mobile appPOST /api/auth-pools/public/:clientId/:actionNone — the client id is in the URL. Off until you turn it on.

Enable a pool

curl
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).

Express
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

ClaimValue
issThe pool's issuer, https://harakumo.com/auth/<project-slug>
audThe pool's client id
subThe end user's id
email, nameAs signed up
email_verifiedtrue once they entered a verification code
poolThe pool id
tvToken version: moves on when the user is reset or signed out everywhere
iat, expIssued 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

  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 (https only; http only for localhost; up to 20). A request from any other Origin is refused before anything runs.
  3. Call the public endpoints with the client id. GET …/session with Authorization: Bearer <token> answers who is signed in.
Browser
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

  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, 1,000 on pro, 20,000 on enterprise). 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 stop verifying.
  • GET /api/auth-pools/:id/secret shows 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 (or keepPrevious: false to 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

SDK
// 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);