Authentication
Three kinds of bearer credential reach the organization API. Each resolves to one organization and one active user, or to the same 401.
Every organization API request outside the login endpoints sends
Authorization: Bearer <credential>. The verifier resolves the credential to
an organization and a user inside a transaction scoped to the organization it
claims, and that transaction is the only database access the request gets.
In every case the organization is the one that owns the key, session, or
token row found under that organization’s row level security scope, never
just a claim.
Session tokens
Logging in (an email code, a password, or an invite) creates a session and returns a
session token: an EdDSA JWT with header kid: "he-session-v1", signed with a
per-organization key the server derives from SERVER_SECRET_KEY with HKDF.
| Claim | |
|---|---|
oid | organization ID |
sub | user id |
sid | session id |
aud | happy-enterprise:org (the organization API) for REST sessions, happy-enterprise:mcp for OAuth grants |
iat, exp | REST sessions last 30 days |
Every request checks the session row: not revoked, not expired, and its user still active. Logging out, revoking a session, and deactivating a user take effect on the next request. A token signed for one organization does not verify in another, because each organization’s key is different.
External JWTs
An organization can bring its own identity provider. The operator registers
the provider’s Ed25519 public key for that organization through the
management API, with a kid of the operator’s
choosing (he- prefixes are reserved). The provider then mints tokens like:
{
"header": { "alg": "EdDSA", "kid": "acme-idp-2026" },
"claims": {
"oid": "c8kq2m7xw3n5p9r1t4v6y0za",
"sub": "h3jd8s0q2l5x7c1v9b4n6m8k",
"aud": "happy-enterprise:org",
"exp": 1793000000,
"amr": ["pwd", "mfa"]
}
}The rules:
algmust beEdDSAandkidmust be present.- The key is looked up under the claimed
oid’s scope, and the signedoidmust equal the key’s owner, so one organization’s key cannot mint tokens for another. submust be an active user of that organization.audmust be the single stringhappy-enterprise:org, the organization API’s audience. An array is rejected, even one containing the right value.expis required;nbfis honored; 30 seconds of clock skew are allowed.amr(RFC 8176) is optional."mfa"in it attests a second factor (see Two-factor authentication).- Revoked keys stop working immediately.
External JWTs work on the REST API only, not on the MCP endpoint.
Agent API tokens
Agents authenticate with API tokens of the form
heat_<org id>_<token id>_<secret>Only the SHA-256 of the secret is stored. A token is shown once, when it is
created: with the agent (POST /v1/agents) or later by an owner
(POST /v1/agents/{agent}/tokens). Owners revoke tokens individually, and
deactivating the agent revokes all of them. API tokens work on the REST API
only.
Logging in with an email code
POST /v1/orgs/{org}/auth/email/start
{ "email": "ada@acme.example" }
202 { "challenge_id": "q9w8e7r6t5y4u3i2o1p0a9s8", "expires_at": "2026-10-24T10:10:00Z" }The answer is always 202 with a challenge id, whether or not the
organization exists and whether or not the address may log in. A 6-digit code
is emailed only if it may. The code expires after 10 minutes and allows five
attempts. Verifying it returns a session token:
POST /v1/orgs/{org}/auth/email/verify
{ "challenge_id": "q9w8e7r6t5y4u3i2o1p0a9s8", "code": "481920" }An address may log in when, in this order:
- it belongs to an active user of the organization (a deactivated user’s address may not);
- it is the organization’s bootstrap owner address and the organization
has no active owner: the user is created and added to
owners; - its domain is exactly one of the organization’s verified domains: the
user is created as a member of
everyone.
Anyone else needs an invite.
Signing in to the server
People do not have to know which organization to sign in to. One emailed code proves an address across the server, and then they choose:
POST /v1/auth/startwith the address always answers202with a challenge. A 6-digit code is emailed only if the address has an organization to enter or may sign up, so the answer says nothing about the address. The code works for 10 minutes and 5 tries.POST /v1/auth/verifychecks it and returns an identity token (heid_..., valid for 15 minutes) and the organizations the address may enter now, each with how:member,owner_claim(the first owner of an organization that has none),domain(a verified domain lets the address join), orinvite(a pending invite).POST /v1/orgs/{org}/auth/identitywith the token opens exactly the session an email code at that organization would, joining the same way, or accepts the address’s pending invite. A required second factor follows as after any login.
The identity token is not a credential: every organization and management endpoint refuses it. It only lists organizations and opens sessions in them, so while it lasts, switching to another organization needs no new code. Passwords stay per organization.
Each listed organization is checked in that organization, by the same rules an email-code login uses, before it is shown. An address is never offered an organization it may not enter, and a deactivated member’s organization is not listed.
Sign-up
With ORG_SIGNUP=open (off by default), someone who has proven their
address this way can create an organization: a name, an address
(slug), and
optionally their own display name. They become its first owner and are
signed in.
ORG_SIGNUP_EMAIL_DOMAINSlimits sign-up to addresses at the listed domains.ORG_SIGNUP_DAILY_LIMIT(3 by default) is how many organizations one address may create per day.- With
TURNSTILE_SECRET_KEYandTURNSTILE_SITE_KEY, sign-in codes are only emailed behind a Cloudflare Turnstile check.
Sign-up proves addresses by email, so it needs a real
email provider: with the log
provider the codes only reach the server log. While sign-up is off,
organizations are created in the operator console only.
Invites
A member with invite_members invites an address. The server emails a link
to INVITE_URL?org=<org id>&token=<token>, where the token is
hei_<invite id>_<secret>. An invite:
- is bound to one address and one organization,
- works once,
- expires after 7 days,
- replaces any pending invite for the same address.
Accepting it (POST /v1/orgs/{org}/invites/accept) proves control of the
invited address, because the token was only ever sent there. It creates the
user if needed (with an optional display_name) and logs in. With
passwords on, it can also set the new user’s password.
While email goes to the log (EMAIL_PROVIDER=log), the invite response
carries the link as invite_url, in the REST API and the MCP tool alike,
for the inviter to copy and pass on.
Signing in with a password
With ORG_PASSWORD_AUTH=true (the default in the
Docker image, off otherwise), people can sign
in with their address and a password:
POST /v1/orgs/{org}/auth/password
{ "email": "ada@acme.example", "password": "..." }The address is only a login name here: nothing is emailed, and no email provider is needed. The session is the same one an email code opens, with the same token, lifetime, and second-factor requirement.
A password signs in only an existing, active person who has one. It never joins anyone: a verified domain or the bootstrap owner address still needs an email code or a link. Passwords are 12 to 256 characters, stored as argon2id hashes. An unknown address is checked against a dummy hash, so it answers like a wrong password, in about the same time.
A password is set:
- when accepting an invite, with
password; - through a set-password link;
- by the user, with
POST /v1/me/password. This needs the current password, if there is one, and a full session: one that has completed its enrolled second factor. It ends the user’s other sessions.
Set-password links work once, within 24 hours, in their organization:
{PUBLIC_BASE_URL}/reset-password?org=<org id>#token=<token>The token is in the fragment, so it is not sent to servers or logged along
the way. Owners make links for their members
(POST /v1/members/{user}/password-link), to reset a forgotten password.
Whoever runs the server makes
owner links, which give an
organization its first owner or reset an owner’s password. Redeeming a link
(POST /v1/orgs/{org}/auth/password/reset) sets the password and ends all
of the user’s sessions.
The web app has all of this: password
sign-in, a password on accepting an invite, the /reset-password page,
Settings → Password, and members’ Reset link.
Failed attempts
Failures back off per account and client IP: after 5 failures, that
pair is locked for a minute, doubling up to an hour, and gets 429 with
Retry-After. Per account, attempts past 20 in 15 minutes from anywhere
are each slowed by a second, but never locked. So someone guessing from
one place cannot lock the real user out from another. The same rules apply
to changing a password and to operators.
Lockouts are kept per server process.
Verified domains
An organization may let anyone at its own email domains join without an
invite. A member with change_security_posture adds a domain and gets back a
DNS record to publish:
_happy-enterprise.acme.example. TXT "happy-enterprise-verification=5f0c1e7a9b2d4c6e8f1a3b5c7d9e0f12"The token is random per organization and domain. The verify endpoint looks
the record up and marks the domain verified only if one of the TXT values is
exactly happy-enterprise-verification=<that token>. Unverified domains admit
nobody. Verification is not re-checked later: removing the record does not
unverify the domain, deleting the domain does. An organization can have up to
50 domains.
Ending sessions
POST /v1/auth/logoutends the caller’s own session.GET /v1/me/sessionsandDELETE /v1/me/sessions/{session}list and end the caller’s REST sessions and OAuth grants.- Owners end every session and OAuth grant of a member with
DELETE /v1/members/{user}/sessions; the member stays active and can log in again. - Deactivating a member ends everything and keeps them out.
One response for every failure
Every authentication failure returns the same response:
401 Unauthorized
WWW-Authenticate: Bearer
{ "error": { "code": "unauthorized", "message": "unauthorized" } }The reason (unknown organization, unknown kid, bad signature, expired,
revoked, deactivated user, wrong audience, wrong password) is logged on the server but never
returned, so responses do not reveal which users, keys, sessions, or
invites exist. (Which organizations exist is public: their
addresses have
sign-in pages.)