# Elchi Docs, every page Source: https://docs.elchi.dev/ (each page is also at its address with .md) --- # EAuth Sign-in for your application on the standards everyone uses, with a second factor, organisations and an export that lets you leave. Free, without user limits. --- # EAuth Authentication for your application, using the same standards everyone else uses, without the pricing that starts the day you succeed. EAuth is an OAuth 2.1 and OpenID Connect provider built and operated by [Elchi Studios](https://elchi.dev) in Oberägeri, canton Zug, Switzerland. ## What you get - **The authorization code flow with PKCE.** Nothing else, because RFC 9700 deprecated the alternatives and we did not implement them. - **OpenID Connect**, so any standard library configures itself from one discovery URL. - **Two-factor authentication** with recovery codes, included rather than sold as an upgrade. - **[Organisations](/organisations)**, if you sell to companies and need employees grouped by employer with per-employer roles. - **A hosted sign-in page** that carries your name, your colour and your logo. ## What it costs Nothing. There is no paid tier, no user limit, and no point at which growth turns into an invoice. There is also no availability guarantee, and we say so in the [terms](https://elchi.dev/en/legal/eauth-terms) rather than publishing a number we are not paid to underwrite. If minutes of downtime are unacceptable to you, run it yourself. ## Why you could leave The export includes your users' password hashes in their original form. You can migrate to another provider without forcing anyone to reset a password. That is deliberate. The largest switching cost in this market is an export that omits the hashes, and a provider that keeps you by making leaving painful has stopped competing on the product. ## Start here The [quickstart](/quickstart) takes about ten minutes and ends with a working sign-in. The [playground](/playground) generates a real authorization request you can paste into a browser. --- # Login with EAuth Ten minutes, ending with a signed-in user and their email address in your application. ## 1. Register your application Go to [panel.elchi.dev](https://panel.elchi.dev) and create an application. Choose the type carefully, because it cannot be changed afterwards: | Type | Use when | Authenticated by | |---|---|---| | **Public** | Browser app, mobile app, anything whose code the user can read | PKCE alone | | **Confidential** | Server-rendered app, backend service | PKCE and a client secret | A single-page application is **public**. Choosing confidential would put a secret into a JavaScript bundle, where it is not a secret. Add your redirect URI exactly as your application will send it. A trailing slash makes it a different URI, and wildcards are refused. ## 2. Point your library at the discovery URL ``` https://eauth.me/.well-known/openid-configuration ``` Every conforming OpenID Connect library takes that one address and configures each endpoint itself. You should not need to hardcode any other URL. ## 3. Send the user to sign in Generate a PKCE pair, keep the verifier, send the challenge: ```js const verifier = base64url(crypto.getRandomValues(new Uint8Array(48))); const challenge = base64url( await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) ); sessionStorage.setItem("pkce_verifier", verifier); const url = new URL("https://eauth.me/authorize"); url.searchParams.set("response_type", "code"); url.searchParams.set("client_id", CLIENT_ID); url.searchParams.set("redirect_uri", REDIRECT_URI); url.searchParams.set("scope", "openid profile email"); url.searchParams.set("state", crypto.randomUUID()); url.searchParams.set("code_challenge", challenge); url.searchParams.set("code_challenge_method", "S256"); location.assign(url); ``` Store the `state` value. You will compare it in the next step. ## 4. Handle the redirect back The user returns to your redirect URI with three parameters: ``` ?code=...&state=...&iss=https://eauth.me ``` Check all three before doing anything else: - **`state`** must equal what you stored. If it does not, the request did not start in this browser session, and you should abandon it. - **`iss`** must be exactly `https://eauth.me`. This is RFC 9207, and checking it is what stops a mix-up attack when your application talks to more than one provider. - **`code`** is single use and expires in sixty seconds. ## 5. Exchange the code for tokens A confidential client exchanges the code from its server, never from the browser, because the request carries its secret: ```bash curl -X POST https://eauth.me/token \ -u "$CLIENT_ID:$CLIENT_SECRET" \ -d grant_type=authorization_code \ -d code="$CODE" \ -d redirect_uri="$REDIRECT_URI" \ -d code_verifier="$VERIFIER" ``` A public client omits `-u` and sends `client_id` in the body instead. A single-page application sends this request from the page itself: EAuth lets a page read the answer when it runs on the origin of one of the application's registered redirect URIs (see [requests from the browser](/sdk#requests-from-the-browser)). You get back an access token, an ID token, and a refresh token if you asked for `offline_access`. New applications may ask for it. For one whose settings leave it out, EAuth leaves it out of the sign-in too, rather than refusing it. ## 6. Read who signed in The ID token is a signed JWT. Verify it against our [JWKS](https://eauth.me/.well-known/jwks.json), then read the claims: ```json { "iss": "https://eauth.me", "aud": "eauth_cnf_...", "sub": "01a06188-ea2a-7c15-8ac2-d33d2eacd4db", "name": "Anna Muster", "email": "anna@example.com", "email_verified": true } ``` `email_verified` is always `true` here: EAuth signs nobody in to your application before they have confirmed their address with the link it mails them. Somebody who registers is asked to open that link first; opened in the browser they registered in, it signs them in and brings them back to you; opened anywhere else, it asks for the account's password as well (see Security). Somebody who signs in with an address not yet confirmed is stopped on the way, with a page that sends a new link. Use `sub` as the user's identity in your database. It never changes. Do not key on `email`: people change addresses, and two different people can hold the same address years apart. ## What to do next The [playground](/playground) builds a real authorization request from your own client id so you can watch the flow happen before writing any code. --- # Playground Enter your own values below. Everything is generated in your browser; nothing is sent anywhere until you follow the link yourself.
Hello {user?.name}
> ); } ``` The provider completes the sign-in when EAuth sends the browser back, continues the session after a reload, and returns the person to the page they started on. ## Svelte and SvelteKit ```bash npm install @elchi-studios/eauth-sveltekit ``` ```ts // src/lib/auth.ts import { goto } from "$app/navigation"; import { createAuth } from "@elchi-studios/eauth-sveltekit"; export const auth = createAuth({ clientId: "eauth_pub_...", redirectUri: "https://app.example/", onReturn: (path) => goto(path, { replaceState: true }), }); ``` `$auth` is `{ user, loading, error }`, and `auth.signIn()`, `auth.signOut()` and `auth.fetch()` do what they say. Nothing touches a browser API during server rendering. ## Nuxt ```bash npm install @elchi-studios/eauth-nuxt ``` ```ts // nuxt.config.ts export default defineNuxtConfig({ modules: ["@elchi-studios/eauth-nuxt"], eauth: { clientId: "eauth_pub_..." }, }); ``` `useEAuth()` is auto-imported, `definePageMeta({ middleware: "eauth" })` requires somebody signed in, and every option can be set per environment with `NUXT_PUBLIC_EAUTH_*`. Without `redirectUri` the application's root is used. ## Without a framework ```bash npm install @elchi-studios/eauth ``` ```js import { EAuth } from "@elchi-studios/eauth"; const auth = new EAuth({ clientId: "eauth_pub_...", redirectUri: location.origin + "/", }); // On every page load. Completes a sign-in when EAuth has just sent the // browser back, and otherwise continues the session. const { user, returnTo } = await auth.ready(); if (returnTo) history.replaceState(null, "", returnTo); if (!user) signInButton.onclick = () => auth.signIn(); ``` ## Calling your own API ```js const response = await auth.fetch("/api/orders"); ``` `fetch` attaches the access token and renews it before it expires; `getAccessToken()` gives the token itself for another client. Your server verifies it as a signed JWT against `https://eauth.me/.well-known/jwks.json` and checks `iss`, `aud` (your client ID) and `exp`. ## Organisations For an application with [organisations](/organisations) turned on, the user carries the one the person signed in for, and a sign-in can name one by its ID or slug, which is also how somebody switches: ```js user.organization; // { id, slug, name, role, permissions } or undefined await auth.signIn({ organization: "acme-ag" }); ``` The same option works on the React, Svelte and Nuxt packages' `signIn`. ## What the SDK does that a tutorial would not **No client secret anywhere.** If you want to pass one, the client is registered as the wrong type. **Tokens in memory by default.** A token in `localStorage` is readable by any script on the page, so one cross-site scripting bug in any dependency hands over the session. After a reload the SDK asks EAuth again with `prompt=none`: one redirect that shows nothing. `storage: "local"` keeps the refresh token across reloads for those who have weighed it. **`state`, `nonce`, `iss`, `aud` and `azp` are all verified**, and a mismatch throws rather than warns. The `iss` check is RFC 9207 and is what stops a mix-up attack when your application talks to more than one provider. **Refreshes never overlap.** Refresh tokens rotate, and a rotated token presented again ends the session. Callers in a page share one refresh, and with `storage: "local"` the tabs take turns. **No redirect loops.** A failed sign-in is shown, not retried; a silent attempt that failed is not repeated; and an answer that does not arrive at the redirect URI is reported as `redirect_mismatch` instead of tried again. ## Requests from the browser The SDK calls four of EAuth's addresses with `fetch` from your page, and EAuth answers them with CORS headers, as narrowly as each allows: | Address | Pages that may read the answer | |---|---| | `/.well-known/openid-configuration`, `/.well-known/jwks.json` | any | | `/userinfo` | any, with the access token in the `Authorization` header | | `/token`, `/revoke` | pages on the origin of one of the application's redirect URIs | A page on `https://app.example.ch` can exchange its code and renew its tokens once a redirect URI on `https://app.example.ch` is registered. From any other origin the browser hides the answer, and the SDK can only report that EAuth could not be reached. Register a redirect URI on every origin the application runs on, `http://localhost:5173/` for development included. No address takes a cookie from another origin, so a request never needs `credentials: "include"`, and one that sets it is refused by the browser. The sign-in pages, the account page and everything else that works with EAuth's own session cannot be read from another origin at all. ## Errors Everything throws `EAuthError` with a `code` you can branch on. | Code | Meaning | |---|---| | `issuer_mismatch` | The answer or a token came from a different server | | `audience_mismatch` | The ID token was issued for a different application | | `nonce_mismatch` | The ID token belongs to a different sign-in | | `token_expired` | The ID token has expired; usually the device clock is wrong | | `redirect_mismatch` | EAuth's answer did not arrive at the redirect URI | | `sign_in_loop` | Three sign-ins in a row failed, so no fourth was started | | `organization_mismatch` | The ID token is for another organisation than the one named | | `access_denied` | The person declined, or is not a member of the organisation named | | `invalid_client` | The client ID is wrong, or the application is confidential: code in a browser needs a public one | ## Self-hosting Pass `issuer` pointing at your own deployment. Everything else is discovered from `/.well-known/openid-configuration`, so no other URL is hardcoded. --- # Migrating from another provider Bring your users across with their password hashes intact. Nobody resets anything, and nobody notices the change. ## Why this matters An export that omits password hashes forces every user to reset. A measurable fraction never come back, which is why several providers restrict hash export: it keeps customers by making departure expensive rather than by being better. We accept the common formats, verify against them on first sign-in, and replace the stored value with Argon2id at that moment. After one login the foreign hash is gone. ## Supported formats | Format | Recognised by | Typical source | |---|---|---| | Argon2id | `$argon2id$` | modern frameworks, and us | | bcrypt | `$2a$`, `$2b$`, `$2y$` | Auth0, Rails, Laravel, most PHP | | scrypt | `$scrypt$` | Node, Firebase | | PBKDF2-SHA256 | `pbkdf2_sha256$` | Django, several frameworks | Detection is by shape, so your export needs one column of hashes and no metadata about which algorithm produced them. ## The import In the console, open your application, then **Users** and **Import accounts**. The owner and admins of an application can import; everybody on its team sees the accounts. The file is a JSON array: ```json [ { "email": "anna@example.com", "display_name": "Anna Muster", "password_hash": "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy", "email_verified": true } ] ``` or a CSV with a header row naming `email` and `password_hash`, and optionally `display_name` and `email_verified` (`true`, `1` or `yes`). A JSON row may carry `id`, the account's id at an earlier EAuth, kept as its `sub` (see below). Up to 100,000 accounts and 25 MB per file. An account imported with `email_verified` false is asked to confirm its address the first time it signs in, before your application gets it. The console checks the file first and shows what it found: how many accounts, in which hash formats. Nothing is written until you confirm. The batch is validated in full and rejected as a whole if any row fails, including a row whose address already has an account at your application. A partially imported user base is worse than a failed import, because you cannot tell which half is missing without comparing row by row against the source. The report names the row and the reason: ``` row 4: "not-an-email" is not an email address row 9: "anna@example.com" already appeared on row 2 row 12: "ben@example.com" already has an account at this application row 17: unrecognised hash format, supported are argon2id, bcrypt, scrypt and pbkdf2-sha256 ``` **From another EAuth.** The file the console's Export everything makes, here or on a self-hosted installation, imports as it is. Its accounts keep their ids, which your application knows as `sub`, so nothing keyed on `sub` has to change; an id that already belongs to an account on the receiving installation is named in the report, and leaving it out gives that account a new one. Accounts that signed in through single sign-on or with passkeys only have no password there and come without one; they set one with a password reset. Organisations, their members and roles, consents and webhooks in the export are not imported: set them up again in the console. Imported accounts sign in with their old password from the moment the import finishes. The list in the console marks the ones still on their imported hash; the mark goes away with their first sign-in. Nothing is sent to your webhook for an import; your system already knows these people. ## Getting the file out of the provider you leave What each provider gives you, as their documentation described it on 28 September 2026, and how it maps onto the import above. Check the page before you rely on it; export rules change. **Auth0.** The self-service bulk export (Management API, `users-exports` job) carries no password hashes. The hashes come from a support case: you supply a 4096-bit PGP public key, a second tenant administrator confirms, and a CISO or an executive at vice-president level signs an acknowledgment ("typed names are not accepted"); the download link expires after three days. The file is JSON with `email`, `email_verified`, `name` and a bcrypt hash in a field named for the password hash; rename that field to `password_hash` and `name` to `display_name`, and the import above accepts it. ([Auth0: export password hashes](https://auth0.com/docs/manage-users/user-migration/export-password-hashes-and-mfa-secrets)) **Clerk.** Self-service: Dashboard, Settings, User exports, a CSV for admins that includes the hashed passwords (bcrypt). Rename the address column to `email` and the digest column to `password_hash`, set `email_verified` from the verification column, and import the CSV. ([Clerk: migrating](https://clerk.com/docs/guides/development/migrating/overview)) **Supabase Auth.** Your database is yours: `select email, encrypted_password as password_hash, email_confirmed_at is not null as email_verified from auth.users` gives the CSV; the hashes are bcrypt. ([Supabase discussion](https://github.com/orgs/supabase/discussions/3897)) **Firebase Authentication.** `firebase auth:export` writes the accounts with their hashes, but Firebase's scrypt is a modified variant with a salt separator and a signer key, not the standard `$scrypt$` we recognise. We do not accept it yet; an import from Firebase carries the accounts without a password, and every person sets one at their first sign-in through the reset link. We say so rather than import a hash that would never verify. **Amazon Cognito.** Cognito does not export password hashes; its documented path is a migration trigger at each user's next sign-in into Cognito, not out of it. Leaving means every person sets a new password. ([AWS: migrate user Lambda trigger](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-migrate-user.html)) **Keycloak, Authentik, Zitadel, FusionAuth, Logto, Hanko, self-hosted.** Your database. Keycloak stores bcrypt or PBKDF2 depending on the realm's policy, in a form the import recognises once the algorithm's string is reconstructed; ask us for the query for your version. ## One note on bcrypt bcrypt silently truncates at 72 bytes. A user whose password is longer has been authenticating on its first 72 bytes at your previous provider, and will continue to until their first sign-in here, at which point the full password is hashed with Argon2id. That is a small security improvement they get for free. ## Leaving again The same door works outwards. **Users**, then **Export everything**, gives you the application as JSON: its settings, its team, its webhooks, its organisations with their members, roles, open invitations and single sign-on connection, the consents, and every account with its password hash as stored. That is Argon2id in the standard PHC string format, which any provider accepting Argon2id can import, or the hash an account was imported with if it has not signed in since. Second-factor secrets and passkeys are not in the export. A passkey is bound to EAuth's domain and would not work anywhere else, and a second-factor secret is better re-enrolled than copied. We would rather you could leave than keep you because you cannot. --- # Passkeys A way to sign in that a convincing copy of the sign-in page cannot capture. ## Why this is different from a password or a code A password or a six-digit code can be typed into a fake page and replayed. A passkey cannot: the browser binds it to the site it was created for and refuses to use it anywhere else. The person does not have to notice the phishing, because the credential does. Every passkey made through EAuth belongs to `https://eauth.me`. That is the only place it works, which is the point. ## What your application does Nothing. Passkeys are on for every application, and signing in with one happens on EAuth's own pages, so your integration receives the same authorization code and the same tokens it gets after a password. There is no setting and no SDK call. ## How a person uses one **Adding one.** On their account page at `https://eauth.me/account`, under Security, with the account's password to confirm. The page needs a sign-in first, and a sign-in starts at your application, since the account belongs to it: link to the account page from your application's settings, where the person is signed in already. A passkey is a way in that survives a password change, so a browser that happens to be signed in is not enough to leave one behind. Every addition is mailed to the account's address, with what to do if it was not them. They can add several, a phone and a hardware key for instance, so losing one is not losing access. **Signing in.** The sign-in page offers the passkey without the address being typed first: as a suggestion in the address field where the browser supports that, and with the button "Sign in with a passkey" everywhere else. The browser shows the passkeys it holds for EAuth, the person picks theirs, and the sign-in continues exactly as after a password. **Removing one.** From the same page. A removed passkey stops working at once. ## Accounts and applications An account at your application signs in to your application only, with a passkey as with a password. Every application's passkeys belong to the same site, so a person with accounts at two applications may be offered both; the one for another application is refused with a message that says so, and nothing is signed in. ## Two-factor accounts A passkey whose authenticator checked the person, with a PIN, a fingerprint or a face, is two factors on its own: something they have and something they know or are. Such a sign-in is complete. A passkey that only registered a touch is one factor, and an account that turned on two-factor is asked for its code as after a password. ## What we store | Value | Why | |---|---| | Credential ID | Identifies which passkey is being used | | Public key | Verifies the signature | | Sign count | Detects a copied authenticator | | Transports | Lets the browser prompt for the right thing | | Name | What the person called it, shown on their account page | There is no private key here and never can be. It stays in the authenticator, which is what makes a copy of our database useless against a passkey. Every challenge works once, for five minutes. An unanswered one is deleted within the hour. ## The counter check An authenticator increments a counter on every use. A value that fails to advance is the documented signal that the credential has been copied. We do not merely refuse that sign-in: the passkey is removed. If it has been copied, the copy works too, so leaving it registered would leave the account open to whoever holds the copy. The person is told to sign in with their password and add a new one. Passkeys that sync through a platform keychain do not keep a counter and always report zero. That is expected and is not treated as a copy. ## Losing a passkey Nothing is lost with it: the account keeps its password. Sign in with that, or reset it by email if it is forgotten, and remove the lost passkey from the account page. --- # Organisations For products sold to companies. The people who use your application belong to the companies that bought it, hold a role in each, and your application learns from the tokens which company a person signed in for and what they may do there. ## Turning them on In the console, open the application, and under **Settings** tick **Enable organisations**. The application's **Organisations** page lists them from then on. Every application starts with two roles, `admin` and `member`, with `member` as the default. Until organisations are on, nobody is asked for one and the tokens carry none, so a consumer product never sees any of this. Turned off later, the sessions go on and their tokens stop carrying organisations; turned on again, the same sessions carry them again. ## What there is **An organisation** is one of your customers: a name, a slug (lower case letters, digits and hyphens, like `acme-ag`, and never in the form of an id) and an id that never changes. **A role** belongs to your application: a key like `billing-admin`, a label for people, and permissions in your own words, like `invoices:read`. EAuth does not interpret permissions; it passes them on. One role is the default for new members. **A member** is an account of your application in an organisation, with one role there and a status, active or suspended. One account can belong to several organisations, with a different role in each. ## Getting people in **By invitation.** On the organisation's page, under Invite, an address and a role. The invitation goes out by mail with a link that works for seven days. The link opens a page on EAuth that joins with a button, never on opening, because mail scanners open every link. Only the account with the invited address can accept; anybody without one creates it from the same page and comes straight back. The browser the link was opened in keeps the invitation for a day while that happens; another browser needs the link again. An invitation goes out five times at most (each time, the earlier link stops working), and can be withdrawn. One address gets at most ten invitation mails from an application a day, however many of its organisations invite it and however often an invitation is withdrawn and made again. **An account that exists.** Under Add an existing account, by address. The person is a member at once, without a mail. **Through the organisation's identity provider.** With [single sign-on](/enterprise-sso), the provider vouches for its people: the account is created at their first sign-in, and the membership too if the organisation wants it. **By domain.** Give the organisation its email domain and add the TXT record the console shows: ``` _eauth.acme.example TXT "eauth-verification=..." ``` Then Verify. From then on you can turn on joining by domain: anybody whose confirmed address is at that domain joins with the default role the next time they sign in. Only a confirmed address counts, since anybody can type an address in. A domain can be verified by one organisation per application, and changing it takes the verification and automatic joining away until the new one is verified. ## At sign-in Pass `organization` to `https://eauth.me/authorize`, with the organisation's id or its slug: ``` https://eauth.me/authorize?client_id=...&redirect_uri=...&response_type=code &scope=openid%20email%20offline_access&code_challenge=... &code_challenge_method=S256&state=...&organization=acme-ag ``` The person then signs in to that organisation, or your redirect URI gets `error=access_denied`: when they are not an active member of it, when it is suspended, and when there is no such organisation. The answer is the same for all three, so it does not tell anybody which organisations exist. Without the parameter: | The person is in | The sign-in | |---|---| | no organisation | carries none; your application decides what such a person may do | | one | carries that one | | several | shows a page to choose, with only their organisations on it | | several, with `prompt=none` | carries the one they used last, since no page may be shown | To let somebody switch, send them to sign in again with the other organisation's slug as `organization`. They are signed in to EAuth already, so it is one redirect and back. A suspended organisation, and a suspended membership, are as if they did not exist: not offered, and refused when named. If the database cannot answer for a moment while a refresh token is being exchanged, `/token` answers `503` with `temporarily_unavailable` and a `Retry-After`, and the refresh token stays valid. Only a refresh token that is no longer allowed gets `invalid_grant`. ## In the tokens When the sign-in was for an organisation, the ID token, the access token and `/userinfo` carry: | Claim | | |---|---| | `org_id` | The organisation's id. Key on this; it never changes. | | `org_slug` | Its slug, which an admin can change. | | `org_name` | Its name, for display. | | `org_role` | The key of the person's role there. | | `org_permissions` | That role's permissions, sorted. | ```json { "sub": "01a06188-ea2a-7c15-8ac2-d33d2eacd4db", "email": "anna@acme.example", "org_id": "01a0dc84-5949-770c-9c29-f3aa5828032f", "org_slug": "acme-ag", "org_name": "Acme AG", "org_role": "billing-admin", "org_permissions": ["invoices:pay", "invoices:read"] } ``` Your API verifies the access token as usual (the signature against the [JWKS](https://eauth.me/.well-known/jwks.json), `iss`, `aud`, `exp`), then checks that `org_id` is the organisation the requested data belongs to, and that `org_permissions` holds what the request needs. Both checks belong on the server: a browser decides what is shown, not what can be read. ## When something changes **A role, or its permissions.** The next refresh carries the change. Access tokens live for 15 minutes, so that is the longest an old role lasts. **A member removed or suspended, an organisation suspended or deleted.** The refresh tokens issued in that organisation stop at once, and your webhook hears `session.revoked` with the reason. A code issued for it and not yet exchanged stops too. Access tokens already issued run out within 15 minutes; a check against your own records closes that gap where it matters. A member removed stays out. Joining by domain does not bring them back at their next sign-in, and an invitation still open for their address is withdrawn with the removal. Inviting them again, or adding them, does bring them back. ## Two-factor authentication An organisation can require it: under Settings, **Members need two-factor authentication**. A member without it who signs in to that organisation is shown a page that leads to setting it up and back to the sign-in; with `prompt=none` your application gets `interaction_required`. Refresh tokens of members without it stop at the next refresh. The application's own setting, **Require two-factor for every user of this app**, works the same way for every sign-in. Two-factor here means an authenticator app on the account. With one set up, no password alone gets in: a sign-in needs the code, or a passkey that checked the person. ## Webhooks | Type | When | `data` | |---|---|---| | `organisation.created` | An organisation was created in the console. | `organisation_id`, `slug`, `name` | | `organisation.deleted` | An organisation was deleted. | `organisation_id`, `slug` | | `organisation.member_added` | Somebody joined: by invitation, added in the console, or by domain. | `organisation_id`, `user_id`, `role`, `via` | | `organisation.member_updated` | A member's role or status changed. | `organisation_id`, `user_id`, `role`, `status` | | `organisation.member_removed` | A member was removed. | `organisation_id`, `user_id` | See [webhooks](/webhooks) for delivery, signatures and retries. ## Leaving **Users**, then **Export everything**, includes every organisation with its members and their roles, and the roles with their permissions, next to the accounts. --- # Enterprise SSO with SAML When a customer says "our staff use Entra ID, can your product support that?", this is the answer. Your application does not change. ## How it fits together EAuth is the **service provider**. The customer's identity provider vouches for the person, and EAuth gives your application the same OpenID Connect sign-in as always, with the organisation in the tokens. ``` employee → your app → EAuth → customer's IdP → EAuth → your app (Entra ID, Okta, Google) ``` Single sign-on belongs to an [organisation](/organisations): that is who buys it, the employer. ## Setting one up In the console, open the application's **Organisations**, then the customer's organisation. 1. **Verify the organisation's email domain**, under Email domain. The domain is how people are sent to the provider, and the provider may only vouch for addresses in it. 2. Under **Single sign-on**, choose **Set up single sign-on**. EAuth's side appears: an **entity ID** and a **reply URL** (the Assertion Consumer Service URL), and metadata to download with both and EAuth's certificate for this connection. 3. The customer's administrator creates an application for it at their provider with those two values, or reads in the metadata. The provider sends the address as the NameID or as an email attribute, and signs its answers. 4. Paste the provider's **federation metadata** into the console, or enter its entity ID, sign-in URL and signing certificate by hand. 5. **Turn on.** The provider must take sign-in requests by HTTP redirect, as Entra ID, Okta and Google Workspace do. Each connection has its own entity ID, reply URL and key pair, so one customer's setup never touches another's. ## Who is sent to the provider **When your application names the organisation.** `https://eauth.me/authorize` with `organization` (its id or slug) sends a person who is not signed in straight on to the provider, without anything to type. This is how a "Sign in with SSO" button in your product works: ```js await auth.signIn({ organization: "acme-ag" }); ``` The sign-in is then for that organisation or for none: a person who reaches another organisation's provider on the way comes back with `access_denied`. **When the address is in the domain.** On EAuth's sign-in page, **Use single sign-on** takes the typed address to its organisation's provider, and so does signing in without a password. Registering with an address in the domain goes to the provider too, since it creates the account there. Everybody else signs in as before. **When your application asks for a recent sign-in.** With `prompt=login`, the provider is asked to authenticate the person again (ForceAuthn), not only to confirm a session it has, and an answer with an earlier sign-in is refused. With `max_age`, the provider's own session counts when it is recent enough; when it is not, the provider is asked again, to authenticate the person. The ID token's `auth_time` is when the provider says the person authenticated, and a session ends no later than the provider says it should (SessionNotOnOrAfter). ## What happens at the first sign-in The first answer from the provider creates the account in your application, confirmed and without a password: the provider vouches for the address. Your webhook hears `user.created`. With **Make people members at their first sign-in** on, the account also joins the organisation with the default role, and your webhook hears `organisation.member_added` with `via` `sso`. Rolling out to two hundred employees is one step, not two hundred. With it off, the account is there but joins nothing: an [invitation](/organisations) makes it a member, and until then your application gets `access_denied` for the organisation. An account that exists with the same address keeps its `sub`, its memberships and everything your application holds for it: - **confirmed** (its owner opened a link sent to the address): it is the same person, and the account is linked as it is. Its password and passkeys keep working wherever the organisation allows them; with **single sign-on only**, below, they no longer count for the organisation. - **never confirmed**: it may be the person's own, from before they opened the mail, or somebody may have registered the address before the organisation set up single sign-on. So the provider's word takes it over, and every other way in goes: the password, passkeys, two-factor authentication and recovery codes, and whatever was signed in with them (`session.revoked`, reason `account claimed through single sign-on`, sent even when no refresh token of yours ended). End your own session for the account when you hear it, and be wary of anything set up in it before: it may have been somebody else. Access tokens issued before run out within fifteen minutes. The address counts as confirmed from then on. Where the organisation allows passwords, the person can set one again with a reset link. A member an administrator removed stays out, whatever the provider says, and so does a suspended one. ## Single sign-on only **Members sign in to the organisation only through the provider** makes it the only door, which is usually what an enterprise buying SSO expects: - a session signed in with a password or a passkey is sent to the provider before anything is issued for the organisation (with `prompt=none`, your application gets `login_required`); - the organisation's refresh tokens end when this takes effect (`session.revoked`, reason `single sign-on required`), and a code or refresh token that came from another kind of sign-in is refused when it is used, so nothing issued before slips through; - a password typed on the sign-in page for an address in the domain is not even looked at: the page goes to the provider. ## What we check The XML signature handling is a maintained library's job. Signature wrapping has broken implementations at large vendors because XML canonicalisation is subtle in ways that look fine in testing, so it is not written by hand here. The library checks the signature against the certificates you gave (never one inside the answer), the destination, the issuer, the times and the recipient. On top of that: **The answer belongs to this browser.** Every request EAuth sends to the provider is tied to the browser that started it. An answer somebody else obtained, posted from another browser, signs nobody in. Without this, an attacker could sign a victim in to the attacker's account. **Only answers to our own requests.** An answer the provider sends on its own (IdP-initiated) is refused, and so is the artifact binding. **Replay.** Every request is answered once, and every assertion id is remembered until the assertion expires. A captured answer posted a second time is refused. **Audience.** The assertion must name this connection's entity ID. **Domain.** The asserted address must be in the organisation's verified domain. Without this, one customer's provider could vouch for an address belonging to another customer's account. When an answer is refused, the person at the sign-in page is told that it was, and the organisation's page in the console shows why: a signature that does not match the certificate, an audience or reply URL the provider has wrong, an address outside the domain, a provider that did not authenticate the person again when asked to, or a session it ends at once. That is where to look while setting a provider up. Saving new settings clears it. ## Attributes Defaults cover Entra ID and Okta: | | Attribute | |---|---| | Email | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress` | | Name | `http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name` | Where a provider differs, set the names under Attributes. Both the full name and the friendly name are matched, because providers disagree about which they send. Without an email attribute, the NameID is taken when it is an address, which is what Entra ID sends by default. ## Certificates The provider's signing certificate is shown with its expiry. Before it rolls the certificate over, paste its new metadata, or both certificates by hand: answers signed with either are accepted until the old one is removed. ## Turning it off Turned off, members sign in with a password or a passkey again. Accounts single sign-on created have neither until they reset a password. Removing the connection deletes its key pair; the provider's application for it stops working. The accounts stay. --- # Webhooks Your application is told when something happens, instead of asking. ## Events | Type | When | `data` | |---|---|---| | `user.created` | Somebody registered an account at your application, or single sign-on created one. Not for accounts you import. | `user_id`, `email`, `email_verified` | | `user.signed_in` | Somebody signed in to your application: every authorization code you exchange. Not on refresh. | `user_id`, `scope` | | `consent.granted` | Somebody agreed to the scopes you asked for. | `user_id`, `scopes` | | `consent.revoked` | Somebody disconnected your application from their account page. | `user_id` | | `session.revoked` | Your refresh tokens for somebody stopped working without you asking: they signed out everywhere, reset their password, a token was used twice, they are no longer in the organisation the tokens were for, their organisation now allows single sign-on only, or single sign-on took over an account nobody had confirmed. | `user_id`, `reason`, and `organisation_id` when the tokens were for one organisation | | `user.password_changed` | Relevant if you cache anything derived from the old one. | `user_id`, `how` | | `user.deleted` | The account was deleted in the console. Delete what you keep about the person. | `user_id` | | `organisation.created` | An organisation was created in the console. | `organisation_id`, `slug`, `name` | | `organisation.deleted` | An organisation was deleted. | `organisation_id`, `slug` | | `organisation.member_added` | Somebody joined an organisation: by invitation, added in the console, by domain, or at single sign-on. | `organisation_id`, `user_id`, `role`, `via` | | `organisation.member_updated` | A member's role or status changed. | `organisation_id`, `user_id`, `role`, `status` | | `organisation.member_removed` | A member was removed from an organisation. | `organisation_id`, `user_id` | An endpoint subscribed to no events receives all of them. ## What a delivery looks like ```http POST /your/endpoint HTTP/1.1 Content-Type: application/json User-Agent: EAuth-Webhooks/1.0 Elchi-Event-Id: 01a06c8a-… Elchi-Signature: t=1788000000,v1=5f3a… { "id": "01a06c8a-…", "type": "consent.revoked", "created_at": "2026-09-04T10:12:00Z", "client_id": "eauth_cnf_…", "data": { "user_id": "…" } } ``` ## Verifying the signature The conventions are Stripe's, so an existing verifier will work with the header name changed. The signature is HMAC-SHA256 over `timestamp + "." + body` using your endpoint's secret. ```js import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret, header, rawBody, toleranceSeconds = 300) { const parts = Object.fromEntries(header.split(",").map(p => p.split("="))); const age = Math.abs(Date.now() / 1000 - Number(parts.t)); if (age > toleranceSeconds) throw new Error("stale"); const expected = createHmac("sha256", secret) .update(`${parts.t}.`).update(rawBody).digest("hex"); if (!timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v1))) { throw new Error("bad signature"); } } ``` Verify against the **raw** request body, before any JSON parsing. A parser that reorders keys or normalises whitespace changes the bytes and the signature stops matching. The timestamp is part of what is signed, so a captured delivery cannot be replayed later even though nothing about it changed. Reject anything older than five minutes. ## Retries A delivery that does not get a 2xx response within ten seconds is tried again. Each event gets eight attempts in all: the first at once, then after 30 seconds, 2, 10 and 30 minutes, and 2, 6 and 12 hours, each wait counted from the attempt before. The last comes about 21 hours after the first; a delivery that fails then is marked abandoned. Every attempt carries the same `Elchi-Event-Id`. If you see one twice, you already handled it; discard the second without parsing. After 25 failed attempts in a row, counted across all its events, the endpoint is disabled and shown as such in the console, where it can be re-enabled once fixed. No mail is sent. While it is disabled, events are not queued for it: what happens in that time is not delivered. A quiet application whose receiver is down for ten minutes loses nothing; a busy one reaches 25 failures sooner, so check the console after an outage of your receiver. The console lists each endpoint's eight most recent deliveries, and any that was not delivered can be sent again. ## Rules for the URL `https` only, a public hostname, no credentials in it. Private and reserved addresses are refused at registration and again at connection time, so a hostname that later resolves to something internal is still refused. Redirects are not followed. Respond fast and do the work afterwards. The request times out after ten seconds, and a slow handler is retried like a failed one. --- # Security Built against RFC 9700, the OAuth 2.0 security best current practice from January 2025, and the March 2026 update draft. ## What is not implemented Three things are absent rather than disabled, so no configuration can bring them back: - **The implicit grant.** Deprecated. It returns tokens in a URL fragment, where they land in browser history and referrer headers. - **The password grant.** Deprecated. It requires your application to handle the user's password, which defeats the purpose of a redirect flow. - **PKCE method `plain`.** Permitted by RFC 7636, but it offers no protection when the request can be observed, which is the case it exists to cover. ## What is enforced **PKCE with S256, always.** There is no code path that issues a token without it, on public and confidential clients alike. **Exact redirect URI matching**, compared in constant time. No wildcards, no prefixes, no normalisation. Every relaxation of this rule has produced a real account takeover somewhere. **Authorization codes live sixty seconds**, are single use, and are bound to the client, the redirect URI and the PKCE challenge. Presenting one twice revokes every token issued from it, because a replay means somebody else saw it. **Refresh tokens rotate on every use.** Presenting an already-rotated token revokes the whole chain. The legitimate client and a thief cannot both hold the newest token, so an old one arriving is evidence of a leak. **The `iss` parameter on every authorization response**, per RFC 9207. **Limits on every door, failing closed.** Every attempt is counted before it is checked, so a burst of parallel requests gets no more guesses than a slow one. Sign-in is counted per address and per account: five wrong passwords for one account from one address, or twenty from anywhere, pause it for up to fifteen minutes. The second step allows five wrong codes in fifteen minutes and twenty in a day, per account. A browser that has signed in to an account before keeps a small allowance of its own while the account is paused for everybody else, so guessing cannot lock the owner out. `/authorize` takes sixty requests a minute per address, `/token` the limit in your application's settings (120 a minute by default, up to 600 there, more on request), counted only once your client has authenticated; for a public client, which has no secret, per address as well. If the counter cannot be reached, these refuse rather than let requests through. A refusal is a `429` with `Retry-After`; the token endpoint answers it as `temporarily_unavailable`. **No page says whether an address has an account.** A wrong address and a wrong password get the same message at sign-in, a password reset answers the same either way, and so does registration: both cases show a page saying a mail is on its way, and only the mail, to the address itself, says whether it confirms a new account or that the address had one already. **Registering somebody else's address gets nothing.** Until an address is confirmed, its account's password, passkeys and two-factor were chosen by whoever filled in the form, who need not own the address. Confirming in the browser that registered, or in one signed in to the account, keeps them. Confirming anywhere else asks the person who opened the mail to choose the password, removes everything set up before and signs out every device, so nobody who registered an address they do not read can sign in once its owner confirms it. A password reset of an unconfirmed account does the same. Until it is confirmed, an account can sign in to its account page, to send a new link, and no application gets a token for it. **Every form carries a token bound to the session**, and must come from the sign-in host itself: the consent screen, the account page, sign-out. **Sign-out needs proof.** `end_session_endpoint` is OpenID Connect RP-Initiated Logout. Send the ID token you hold as `id_token_hint`, with `client_id` and a registered `post_logout_redirect_uri`, and the session ends and the person returns to you with your `state`. Without a hint that names the signed-in person, EAuth asks them first, so no page can sign anybody out with a link, and afterwards it shows its own sign-in page rather than an address nobody vouched for. ## How credentials are stored | Value | Stored as | |---|---| | Passwords | Argon2id, 64 MiB, t=3, p=2 | | Refresh tokens, codes, recovery codes | SHA-256 digest only | | Client secrets | SHA-256 digest only | | Invitation, confirmation and reset links | SHA-256 digest only | | Token signing keys | AES-256-GCM, key held outside the database | Nothing stored as a digest can be read back, including by us. A client secret shown once at creation is genuinely the only time it exists in readable form. Signing keys are decrypted only in the server's memory, to sign; a copy of the database holds them as ciphertext. ## How long things are kept | Data | Kept | |---|---| | An account, its password hash, name and second factor | Until it is deleted: 30 days after its owner asks, from the account page, with everything that is only its own. An account not signed in to for 24 months is written to twice, 30 days apart, and deleted 30 days after the second mail unless it signs in | | Sessions and refresh tokens, each with the browser and platform it was started from ("Firefox on Windows", never the User-Agent header itself) and the country | Until they expire; a revoked one until it would have expired, so a stolen copy presented later is recognised and ends the rest | | Authorization codes | An hour past their 60 seconds | | Confirmation and reset links | A week after they were used or expired | | Invitations | 30 days after they were accepted or expired | | The token-request log, with the country and time of each sign-in | 12 months | | The audit log | 12 months | | Webhook deliveries, with their payloads | 30 days, the time they can be replayed | A sweep in every server removes what is due once an hour. ## What we have not done No independent security audit has been performed. We state this in the terms as well, because the alternative is letting you assume otherwise. When one is completed, the result is published here regardless of outcome. ## Reporting a vulnerability Write to **security@elchi.dev**, in German or English. Every host under elchi.dev names the same address in its `/.well-known/security.txt`. A report we can act on says where, meaning the address or the endpoint, what you did, what you saw, and what somebody could do with it. Steps we can repeat are worth more than the output of a scanner. What you can expect from us: - A reply within three working days, from a person who has read the report. - Once we can reproduce it, the date we expect it fixed, and word when the fix is live. - No legal action against research that keeps to the rules below. The rules: - Use your own accounts, and applications you registered yourself. The console lets anyone register one. - Do not read, change or delete data that is not yours. If you reach some, stop there and tell us what you saw. - No load tests, no denial of service, no spam, no social engineering of our team, no physical access. - Give us time before you publish: ninety days, or less once the fix is live and we have agreed a date. There is no reward programme. In scope is every host under elchi.dev that answers with a `security.txt`. --- # EAuth compared Written to be useful rather than flattering. The figures are from each provider's own pricing page or documentation, read on 28 September 2026; the link after every row is the page they came from. Prices change, so check the page before you decide. The section at the end lists where you should pick something else. To price your own setup, with the rule behind every figure, use the [cost page](https://devs.elchi.dev/compare). ## At a glance | | EAuth | Auth0 | Clerk | WorkOS | Zitadel | Supabase Auth | |---|---|---|---|---|---|---| | Free user limit | none | 25,000 MAU | 50,000 MRU | 1,000,000 | 100 daily active | 50,000 MAU | | Self-hostable | yes | no | no | no | yes | yes | | MFA on the free plan | yes | no | yes | yes | yes | yes | | Organisations | included | 5 free, 10 on paid plans | $100 a month add-on | included | included | build your own | | Enterprise SAML | included, per organisation | 1 free; Enterprise above 5 | 1 included, then $75 each | $125 each | included | per SSO user | | Custom domain | included | 1 included | included on Pro | $99 a month | Pro ($100) | $10 a month | | Password hash export | self-service | support case with executive signature | self-service CSV | not documented | through the API | direct database access | | Swiss region | yes, and the EEA | no | not stated | no | Google Cloud Zurich | AWS Zurich | | Independent audit | no | SOC 2, ISO 27001 | SOC 2 | SOC 2 | ISO 27001, SOC 2 | SOC 2 | | Prebuilt UI components | not yet | yes | yes | yes | partial | partial | Sources: [Auth0](https://auth0.com/pricing), [Clerk](https://clerk.com/pricing), [WorkOS](https://workos.com/pricing), [Zitadel](https://zitadel.com/pricing), [Supabase](https://supabase.com/pricing). ## Where the money goes At most hosted providers the bill is not the users. It is the second enterprise customer, the organisations add-on and the custom domain. The table lists those lines as published; "contact sales" means the page gives no number. | Provider | Base plan | Each enterprise SAML connection | Organisations | Custom domain | Where self-service ends | |---|---|---|---|---|---| | EAuth | $0 | included | included | included | it does not | | Auth0 | Free; Essentials $35 for 500 MAU; Professional $240 | 1 free, 3 on Essentials, 5 on Professional | 5 free, 10 paid | 1 included | above 25,000 MAU or 5 connections: Enterprise, contact sales | | Clerk | Pro $25 (50,000 MRU, then $0.02) | 1 included, then $75 a month each | $100 a month add-on | included | above 15 connections | | WorkOS | AuthKit free to 1,000,000 users | $125 a month each (1 to 15), $100 (16 to 30) | included | $99 a month | Enterprise with 99.99 percent SLA | | Stytch | free to 10,000 MAU | 5 included, then $125 each | included | branding removal $99 once | the per-user rate above 10,000 is not published | | Logto | free to 50,000 MAU; Pro $24 | $48 a month each | $48 a month | $48 a month | Enterprise for a custom data region | | Hanko | free to 10,000 MAU; Pro $29 + $0.01 per user above | $49 a month each | not listed | included | Enterprise | | Descope | free to 7,500 MAU; Pro $249 (10,000 MAU, then $0.05) | 3 free, 5 on Pro, then $50 each | 10 free tenants, 35 on Pro | included | Growth $799 for EU residency | | Kinde | free to 10,500 MAU; Plus $75 | 1 on Free and Pro, unlimited on Plus | 5, 50, unlimited | included | Scale $250 | | Zitadel | free with 100 daily active users; Pro $100 | included | included | Pro | Enterprise for 99.99 percent | | Supabase Auth | free to 50,000 MAU; Pro $25 | $0.015 per SSO user after 50 | not a feature | $10 a month | Team $599 | Sources: [Auth0](https://auth0.com/pricing), [Clerk](https://clerk.com/pricing), [WorkOS](https://workos.com/pricing), [Stytch](https://stytch.com/pricing), [Logto](https://logto.io/pricing), [Hanko](https://www.hanko.io/pricing), [Descope](https://www.descope.com/pricing), [Kinde](https://kinde.com/pricing/), [Zitadel](https://zitadel.com/pricing), [Supabase](https://supabase.com/pricing). Two notes on the table. Auth0's B2B Professional price left the pricing page in November 2023 and is invoice-only since; a competitor cites $800 a month for 1,000 MAU, which we cannot verify. Zitadel's Pro plan states its included daily-active-user quota in a way we could not read unambiguously; ask them before relying on it. ## Leaving What it takes to get your users out, password hashes included, so nobody has to reset a password. | Provider | Hash export | How | |---|---|---| | EAuth | yes | Self-service in the console, any time, argon2id and imported hashes in their original form. [Migrating](/migrating) | | Clerk | yes | CSV from the dashboard, for admins. [Clerk docs](https://clerk.com/docs/guides/development/migrating/overview) | | Supabase Auth | yes | Direct SQL access to `auth.users`. [Supabase](https://github.com/orgs/supabase/discussions/3897) | | Zitadel | yes | Through the API; self-hosted, it is your database. [Zitadel](https://zitadel.com/docs/legal/service-description/cloud-service-description) | | Auth0 | yes, by request | A support case with a 4096-bit PGP key, confirmation by a second administrator and a signed acknowledgment from a CISO or a vice-president; the link expires after three days. The self-service bulk export has no hashes. [Auth0 docs](https://auth0.com/docs/manage-users/user-migration/export-password-hashes-and-mfa-secrets) | | Amazon Cognito | no | Migration out runs through a Lambda trigger at each user's next sign-in. [AWS docs](https://docs.aws.amazon.com/cognito/latest/developerguide/user-pool-lambda-migrate-user.html) | | Keycloak, Authentik, FusionAuth, Logto, Hanko (self-hosted) | yes | Your database. | | WorkOS, Stytch, Kinde, Descope | not documented | We found no export process on their pages; ask before you sign. | ## Where your data is | Provider | Region for a Swiss or EU customer | Legal entity | |---|---|---| | EAuth | Switzerland and the EU, at European hosting providers; mail sent by Resend, Inc. (US) from its EU region | Elchi Studios, a brand of Krauss Software, the sole proprietorship of Samuel Krauss, Oberägeri ZG, Swiss law | | Zitadel | Switzerland (Google Cloud Zurich), EU, US, Australia, on every plan; data in transit not guaranteed to stay in region | CAOS AG, St. Gallen, with a San Francisco presence and US investors | | Supabase | Zurich (AWS eu-central-2) and several EU regions | Supabase Inc., US | | Hanko | Germany (AWS Frankfurt) only | Hanko GmbH, Kiel | | Auth0 | US, EU, Australia, Japan; no Swiss region listed | Okta, US | | Clerk, WorkOS, Stytch | US; WorkOS argues sign-in may run through the US while your data stays in the EU | US companies | | Descope | EU residency from Growth ($799 a month) | US | | Logto | custom data region on Enterprise | US | | Kinde | choice of residency on every plan; regions not listed | Australia | Sources: [Zitadel](https://help.zitadel.com/where-is-zitadel-cloud-data-stored), [Supabase](https://supabase.com/docs/guides/platform/regions), [Hanko](https://www.hanko.io/pricing), [WorkOS](https://workos.com/blog/data-residency-for-enterprise-saas), [Descope](https://www.descope.com/pricing), [Logto](https://logto.io/pricing), [Kinde](https://kinde.com/pricing/). A US company answers to US law wherever the servers stand (the CLOUD Act); a Swiss region at a US cloud does not change that. EAuth is operated by Elchi Studios, a Swiss sole proprietorship, and runs at European hosting providers without a US parent. Its mail is the exception: Resend, Inc., a US company, sends the address confirmations, password resets and invitations from its EU region ([Sub-processors](/subprocessors)). Whether that matters is your buyer's question, not ours to answer. ## Availability No provider in this list publishes its measured uptime. They publish service-level agreements: 99.5 percent at Zitadel by default, 99.99 on the enterprise plans of Clerk, WorkOS, Stytch, Ory and Hanko, with credits you claim within a deadline. Clerk publishes postmortems, including four days of intermittent sign-in failures in September 2025. The hosted EAuth promises no availability. Every incident is on the status page at [status.elchi.dev](https://status.elchi.dev), and the measured availability appears there once a full month has been measured. That is honest, and for some products it is also disqualifying. Sources: [Zitadel SLA](https://zitadel.com/docs/legal/service-description/service-level-description), [Clerk postmortem, September 2025](https://clerk.com/blog/2025-09-18-database-incident-postmortem). ## Where EAuth is the better choice **You do not want a bill that scales with success.** The commercial providers are inexpensive until you grow, at which point the cost per user is a meaningful line item. **You sell to companies.** Your second enterprise customer costs $75 a month at Clerk, $125 at WorkOS and Stytch beyond the fifth, and your sixth moves you to an Enterprise contract at Auth0. Here every organisation can bring its own IdP, and it is not a line on an invoice. **You want the option to self-host.** One Go binary, PostgreSQL, Redis. The same code runs both hosted and on your own machine. **You want to be able to leave.** The export contains password hashes, and it takes a click rather than a signature from a vice-president. ## Where you should pick something else **You need prebuilt UI components today.** Clerk's `Danke, wir haben alles.
"}' ``` `from` must be one of your `sendFrom` addresses. The message is DKIM signed, filed in Sent and queued; a retry with the same `Idempotency-Key` within a day returns the first answer instead of sending twice. ## 5. Keep up Two ways. Poll for changes with the `modseq` you last saw: ```bash curl -s "https://mail.emxmail.app/api/accounts/me/changes?since=812" -H "Authorization: Bearer $EMX_TOKEN" ``` Or be told: a [webhook](/emx-webhooks) on `message.received` calls your URL within the second, signed. For a browser or a long-running process, `/api/accounts/me/events` is a server-sent event stream of `change` events. ## Errors Every error is `{"error": {"code": "…", "message": "…", "requestId": "…", "docs": "…"}}` with a fitting status. `code` is stable and listed under [Errors](/emx-errors); `message` is for a person and may change. `requestId` is what to quote when you write to us. --- # EMX webhooks A webhook is a URL of yours that hears about an account's events. Make one in the web client under Settings, Webhooks, or with the API: ```http POST /api/accounts/me/webhooks Authorization: Bearer emx_… Content-Type: application/json {"url": "https://crm.example.ch/emx", "events": ["message.received", "delivery.failed"], "description": "CRM"} ``` The answer carries the signing secret (`whsec_…`) once. Twenty webhooks per account. The URL must be `https` on a public host; private addresses are refused when the hook is made and again every time it is sent to. ## Events | Event | When | `data` | |---|---|---| | `message.received` | a message was filed in the account, other than a Sent copy or a mail app's upload | `message`: `id`, `threadId`, `mailboxId`, `subject`, `from`, `to`, `cc`, `receivedAt`, `size`, `hasAttachments`, `messageId`, `sealed` | | `delivery.failed` | a message the account sent could not be delivered to one recipient | `queueMessageId`, `recipient`, `status` (`5.1.1` and the like), `reason`, `remoteServer` | | `ping` | you pressed Test | `message`, `sentAt` | ## What a delivery looks like ```http POST /emx HTTP/1.1 Content-Type: application/json User-Agent: EMX-Webhooks/1.0 X-EMX-Event: message.received X-EMX-Delivery: 01a0d3af-44b8-7411-9c92-9ff6c3a4d467 X-EMX-Attempt: 1 X-EMX-Signature: t=1790256990,v1=5f3a… { "id": "evt_01a0d3af44b874119c929ff6c3a4d467", "type": "message.received", "createdAt": "2026-09-24T13:56:30.123Z", "account": {"id": "…"}, "data": {"message": {"id": "…", "subject": "Bestellung 4711", "from": {"address": "anna@beispiel-ag.ch", "name": "Anna Beispiel"}, "…": "…"}} } ``` The body never contains a message's text; fetch that with the API if you need it. A sealed account's `message.received` events leave out the subject and the addresses, which the seal protects. ## Verifying the signature `X-EMX-Signature` is `t=Gerne.
", "inReplyTo": "abc@beispiel-ag.ch", "references": ["abc@beispiel-ag.ch"], "attachments": [{"filename": "Offerte.pdf", "contentType": "application/pdf", "data": "