# 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.
## What the playground does It generates a code verifier and its S256 challenge, assembles the authorization URL, and shows you the exact `curl` command for the token exchange with your verifier already filled in. The verifier never leaves the page. That is the point of PKCE: the value that proves the token request came from the same client that started the flow is known only to that client. --- # JavaScript SDK Four packages. The core does the protocol and depends on nothing; the others only turn its state into the framework's. | Package | For | |---|---| | `@elchi-studios/eauth` | any web page | | `@elchi-studios/eauth-react` | React 18 and 19, Next.js | | `@elchi-studios/eauth-sveltekit` | Svelte 4 and 5, SvelteKit | | `@elchi-studios/eauth-nuxt` | Nuxt 3 and 4 | Create the application in the console as a **public** client and register the redirect URI exactly as you pass it. There is no secret to pass: a browser cannot keep one. An access token lives 15 minutes. The SDK asks for `offline_access` so it can renew the token before it runs out. New applications may have it; for one whose settings leave it out, EAuth leaves it out of the sign-in, and the session ends with the access token. ## React ```bash npm install @elchi-studios/eauth-react ``` ```tsx import { EAuthProvider, SignedIn, SignedOut, SignInButton, useEAuth, } from "@elchi-studios/eauth-react"; export default function App() { return ( ); } function Profile() { const { user, signOut } = useEAuth(); return ( <>

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 `` is genuinely excellent, and we do not have an equivalent yet. If dropping one component into Next.js and being finished matters more than anything else, use Clerk. **You need an independent audit for procurement.** We have not had one. If your buyer's security questionnaire requires SOC 2 or ISO 27001, Auth0, Clerk, WorkOS, Stytch and Zitadel can answer it and we cannot. **You need directory sync.** SAML single sign-on is there, per organisation, with accounts created at the first sign-in. SCIM, which creates and removes accounts when the customer's directory changes, is not. Auth0, WorkOS and Zitadel do this well. **Your application cannot tolerate any downtime.** The hosted EAuth gives no availability guarantee. Self-host in that case. **You need a large ecosystem of ready-made integrations.** Auth0 has a decade of them. **Your setup fits inside a large free tier and you need none of the add-ons.** Then Auth0, Clerk, Supabase or WorkOS cost what we cost, until the day they do not. --- # Changelog What we have told developers about EAuth, newest first: every change to the terms, every new sub-processor and every change that touches integrations, with the day it takes effect. Each was also sent by mail to the address on every account, with the notice the terms promise: 30 days for the terms (14.1) and for a sub-processor (8.5), 90 days for a change that breaks integrations (2.3). ## 3 October 2026 - **EAuth moves to eauth.me.** The issuer is `https://eauth.me`. Every request to auth.elchi.dev is answered with a permanent redirect to the same path on eauth.me (terms 1.6 and 1.7, clause 2.4). - **Terms 1.8.** The companies that run our cluster are listed under [Sub-processors](/subprocessors) (8.6). The status page at [status.elchi.dev](https://status.elchi.dev) is live (4.3). - **Only confirmed addresses.** An application gets no tokens for an account whose email address is not confirmed, so `email_verified` is always true in the tokens of a sign-in. Someone who has not confirmed is asked to on the way back to the application; with `prompt=none` the application gets `interaction_required`. None of these was announced ahead or sent by mail: on that day every application registered with EAuth belonged to Elchi Studios, and no one outside Elchi Studios had an account, so nobody else was affected. --- # Sub-processors A sub-processor is a company we use to run EAuth that, in doing so, processes personal data of your end users. The [terms](https://eauth.me/legal) authorise the ones listed here (8.5, Annex B.8). Before one is added, every developer is told by mail and on the [changelog](/changelog), 30 days ahead; whoever objects on data protection grounds within those days and cannot be accommodated may end their account and export their data. ## The list Since 3 October 2026 EAuth runs on our own cluster: eight servers at five providers in Switzerland, Germany, the Netherlands and France. These companies were added on that day without the 30 days' notice, because every application registered with EAuth then belonged to Elchi Studios and nobody else had an account (terms, clause 8.6). | Company | What it does | What it sees | Where | |---|---|---|---| | Infomaniak Network SA | Runs an application server, the object storage for uploaded pictures, and the server that watches the others | Everything EAuth stores, encrypted at rest as Annex C describes; profile pictures and logos | Geneva, Switzerland | | Tavuru | Runs the primary database server | Everything EAuth stores, as above | Frankfurt, Germany | | Hetzner Online GmbH | Runs a database replica and one of the two edge proxies, where TLS ends | Everything EAuth stores, as above; every request in transit | Nuremberg and Falkenstein, Germany | | Scaleway SAS | Runs an application server, a database replica and the nightly copy of the pictures | Everything EAuth stores, as above | Amsterdam, Netherlands and Paris, France | | UpCloud Oy | Runs the second edge proxy, where TLS ends | Every request in transit | Amsterdam, Netherlands | | ClouDNS Ltd. | Answers the one DNS name behind every host with the edges that are healthy | DNS queries only, no request or account content | Sofia, Bulgaria (EU) | | Resend, Inc. | Sends the mails EAuth sends: address confirmations, password resets, invitations | The recipient's address, the subject and the text of each mail, for as long as its logs keep them | Sent from the European Union region | Every server is in the European Union or Switzerland, and no third party's proxy sits in front of them: a request is encrypted from the browser to our own edge proxy, and from there onward over our own encrypted network between the servers. That is the whole list. In particular, nobody else sees sign-in data: there is no analytics, no error reporting service and no support tool outside our own. Two services see something that is not personal data and are listed for completeness: Cloudflare holds the DNS zones of our domains and answers name lookups only, so it never sees a request or a sign-in; and when a password is checked against known breaches, only the first five characters of its SHA-1 digest are sent to Have I Been Pwned, from which neither the password nor the person can be recovered. ## Changes Every addition is a notice on the [changelog](/changelog), with the day it takes effect. A company removed from the list stops processing your data on the day named there. --- # EMX Mail on your own domain, in the browser and in every mail app, with the API the web client itself uses, signed webhooks and mailboxes that can be sealed. --- # EMX EMX is business mail made by Elchi Studios: your domain, mailboxes for your people, shared inboxes for the company, a web client, and the same mail in Outlook, Apple Mail, Thunderbird and the phone's mail app. EMX is operated by Krauss Software, the sole proprietorship of Samuel Krauss in Oberägeri ZG, under Swiss law. It runs on servers we rent from providers in Switzerland and the European Union: the messages are stored in Geneva, encrypted; the database is in Germany; and copies are kept in Germany, the Netherlands and France. The [terms](https://elchi.dev/en/legal/emx-terms), Annex A, name every provider and what it sees. For a developer, three things matter: - **One API.** The web client talks to `https://mail.emxmail.app/api` with the same JSON API a token gets. Everything the client can do, a program can do, within the token's scopes and the person's rights. There is no second, smaller API to keep in step. - **Webhooks.** `message.received` and `delivery.failed`, signed, retried, with a delivery id to deduplicate on. - **Standards.** IMAP, SMTP submission, SPF, DKIM, DMARC, MTA-STS, autoconfiguration. A mail app is set up by typing an address. ## What EMX does, and does not do EMX has: mailboxes, shared mailboxes, groups and aliases on your domains; shared mailboxes in Outlook and Apple Mail as well as in the browser; automatic replies when someone is away, forwarding, and rules that file mail on the server; a filter for spam on incoming and outgoing mail, which refuses attachments that are programs, also inside archives; [sealed mailboxes](/emx-api#sealed-mailboxes), where the organisation allows them, whose messages, subjects, senders and recipients included, are stored encrypted to a key only the person can unlock; and an export of everything, at any time. A scan for known viruses runs only while a virus scanner is connected to EMX. Today none is: the platform EMX runs on does not offer one yet, so mail is not yet scanned for known viruses. EMX does not have, and promises no date for: a calendar or contacts (CalDAV, CardDAV), Exchange ActiveSync, a mobile app of its own (the web client and the phone's own mail app are the way on a phone), or signed installers for the desktop app. EMX is not an archive for the records the law makes a business keep. ## The parts | Part | Where | |---|---| | Web client and API | `https://mail.emxmail.app` | | Mail server (SMTP 25, submission 465 and 587, IMAP 993 and 143) | `mx.emxmail.ch` | | OpenAPI 3.1 | `https://mail.emxmail.app/api/openapi.json` | | Organisation, plan, tokens | the settings in the web client, or the Elchi panel | | SDK for Rust, `emx` command line, desktop app (installers not signed) | [github.com/Elchi-Studios/emx-client](https://github.com/Elchi-Studios/emx-client) | ## Plans | Plan | For | Includes | |---|---|---| | Private | one person's business | free; one domain, one mailbox of 10 GB, aliases | | Team | a company | CHF 4 per mailbox and month, CHF 3 with your own Resend key; 50 GB per mailbox, shared inboxes, groups, membership rules, the admin interface | | Enterprise | on request | larger limits, in a contract of its own | No VAT is charged: Krauss Software is not registered for VAT. The checkout shows what is charged before you pay. EMX is for businesses only, on every plan, and has no trial period. Sending is limited per person and hour (300 recipients outside EMX), per person and calendar day (200 on Private, 1,000 on Team) and per organisation and month (1,000 on Private, 30,000 on Team). Automatic replies count against these limits, and go only to senders whose domain passes SPF or DMARC. Receiving is limited only by the size of a message and the mailbox's storage. [Acceptable use](/emx-abuse) has every limit. ## Where to go next - [Quickstart](/emx-quickstart): a token, your inbox, a message sent, in five minutes. - [API reference](/emx-api): every endpoint. - [Webhooks](/emx-webhooks): be told instead of asking. - [Mail apps](/emx-mail-apps): IMAP, SMTP, autoconfiguration, the Apple profile. - [Errors](/emx-errors): every code the API answers with. ## The legal side - [Terms](https://elchi.dev/en/legal/emx-terms), with the data processing agreement, the sub-processors and the security measures as built. - Support: [contact@elchi.dev](mailto:contact@elchi.dev), or the console at panel.elchi.dev. - [Acceptable use and abuse](/emx-abuse): what EMX may not be used for, and where to report it. - [Requests from authorities](/emx-authorities): when we hand data over, and what data exists. --- # EMX quickstart ## 1. A token In the web client, Settings, Developer API: name it, pick `mail:read` (or `mail:write` to send and change things), choose when it expires. The token is shown once and starts with `emx_`. ```bash export EMX_TOKEN=emx_… ``` A token acts as you, never with more rights than you have. It cannot open somebody else's mailbox, and it cannot make more tokens. ## 2. Who am I ```bash curl -s https://mail.emxmail.app/api/me -H "Authorization: Bearer $EMX_TOKEN" ``` ```json { "id": "…", "name": "Samuel Krauss", "role": "owner", "accounts": [ {"id": "…", "kind": "user", "name": "Samuel Krauss", "address": "samuel@elchi.dev", "rights": {"own": true, "read": true, "write": true, "delete": true, "sendAs": true}}, {"id": "…", "kind": "shared", "name": "Kontakt", "address": "contact@elchi.dev", "rights": {"read": true, "write": true, "sendAs": true}} ], "sendFrom": [{"address": "samuel@elchi.dev", "mode": "own", "primary": true}, {"address": "contact@elchi.dev", "mode": "as"}], "limits": {"maxMessageBytes": 52428800, "dailyRecipients": 1000} } ``` `accounts` is what you may open. Every mail call names one of them as `{account}`; `me` is your own. ## 3. The inbox ```bash curl -s "https://mail.emxmail.app/api/accounts/me/mailboxes" -H "Authorization: Bearer $EMX_TOKEN" ``` Find the mailbox with `"role": "inbox"` and list it, newest first: ```bash curl -s "https://mail.emxmail.app/api/accounts/me/messages?mailbox=$INBOX&limit=20" \ -H "Authorization: Bearer $EMX_TOKEN" ``` Each message has `id`, `threadId`, `subject`, `from`, `to`, `snippet`, `keywords` (`$seen`, `$flagged`, …), `hasAttachments` and `modseq`. One message with its body: ```bash curl -s "https://mail.emxmail.app/api/accounts/me/messages/$ID" -H "Authorization: Bearer $EMX_TOKEN" ``` `text` is the plain text, `html` is sanitised (no script, remote images held back in `data-src`), `attachments` carry a `part` path you fetch with `/messages/$ID/parts/$PART`. ## 4. Send ```bash curl -s https://mail.emxmail.app/api/send \ -H "Authorization: Bearer $EMX_TOKEN" \ -H "Content-Type: application/json" \ -H "Idempotency-Key: order-4711-confirmation" \ -d '{"from": "contact@elchi.dev", "to": "Anna Beispiel ", "subject": "Bestellung 4711", "text": "Danke, wir haben alles.", "html": "

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=,v1=`, where `v1` is HMAC-SHA256 with your secret over `.`. Check it before you trust the body, and refuse a timestamp older than five minutes: ```go func verify(secret, header string, body []byte) bool { var ts, v1 string for _, part := range strings.Split(header, ",") { if k, v, ok := strings.Cut(part, "="); ok { switch k { case "t": ts = v case "v1": v1 = v } } } unix, err := strconv.ParseInt(ts, 10, 64) if err != nil || time.Since(time.Unix(unix, 0)).Abs() > 5*time.Minute { return false } mac := hmac.New(sha256.New, []byte(secret)) mac.Write([]byte(ts + "." + string(body))) return hmac.Equal([]byte(hex.EncodeToString(mac.Sum(nil))), []byte(v1)) } ``` ```js import { createHmac, timingSafeEqual } from 'node:crypto'; export function verify(secret, header, body) { const parts = Object.fromEntries(header.split(',').map((p) => p.split('='))); if (Math.abs(Date.now() / 1000 - Number(parts.t)) > 300) return false; const want = createHmac('sha256', secret).update(`${parts.t}.${body}`).digest('hex'); return want.length === parts.v1.length && timingSafeEqual(Buffer.from(want), Buffer.from(parts.v1)); } ``` Use the raw request body as received, not a re-serialised copy. ## Retries and failures Answer any 2xx within 30 seconds; do the work afterwards. Anything else, or no answer, is tried again after one minute, five, thirty, two hours and then every twelve, eight times in all. `X-EMX-Delivery` is the same on every retry, so you can ignore what you already handled. Redirects are not followed. A hook whose deliveries fail for good twenty-five times in a row is turned off with a reason you see in the settings; turn it on again with `POST /api/webhooks/{id}/enable` once your side is fixed. `GET /api/webhooks/{id}/deliveries` shows what went out, with status, attempts and the next try. --- # EMX in mail apps Every mail app works with EMX; most need nothing but the address. ## The settings | | | |---|---| | Incoming (IMAP) | `mx.emxmail.ch`, port 993, SSL/TLS (or 143 with STARTTLS) | | Outgoing (SMTP) | `mx.emxmail.ch`, port 465, SSL/TLS (or 587 with STARTTLS) | | Username | your address | | Password | an app password | Your account password never goes into a mail app. Under Settings, Mail apps in the web client you make an **app password** per device, shown once, revoked on its own. A password made for the phone can be revoked when the phone is lost without touching anything else. ## Apple: one tap On an iPhone, iPad or Mac, press **Apple profile** in the same settings. EMX makes an app password for the device and hands you a configuration profile with the account inside; installing it sets up Mail. The profile is not signed, so the device says so once. Removing the profile removes the account; revoking the app password does the same from our side. ## Thunderbird, Outlook and the rest Type your address. Thunderbird and the apps that share its format read `autoconfig.`; Outlook reads `autodiscover.`; both names are CNAMEs to us that the DNS assistant in the admin interface writes with the other records of the domain. The domain's DNS also carries SRV records for IMAP and submission (RFC 6186) for apps that look there. ## What the app sees The folders are the ones in the web client, with their roles announced (SPECIAL-USE), so an app files sent mail and drafts in the right place without being told. Flags are shared: a message read on the phone is read in the browser, and the other way round, within the second. Snoozed and Screener are folders like any other to an app. The shared mailboxes you are a member of appear in the app beside your own, with the rights you have in them. A **sealed** mailbox cannot be read by a mail app: its messages are ciphertext that only the person's own devices can open, in the web client. The app is told so at sign-in. EMX has no calendar and no contacts, so there is nothing to set up for CalDAV or CardDAV, and no Exchange ActiveSync: Outlook and the phone's mail app connect over IMAP. ## Limits Uploads over IMAP (a mail app filing a sent message, an import) count against the mailbox's storage and the message size limit, 50 MB. Sending from an app goes through the same rules as the web client: sender rights, the daily and monthly limits, DKIM signing. --- # EMX API reference Base URL `https://mail.emxmail.app`. JSON in and out, UTF-8, times in RFC 3339 (UTC), IDs are UUIDs. The OpenAPI 3.1 description is at `/api/openapi.json`; `/api` lists the endpoints. A client in Rust, the `emx` command line and the desktop app are open source at [github.com/Elchi-Studios/emx-client](https://github.com/Elchi-Studios/emx-client), and anyone can build their own against the same API. ## Authentication ```http Authorization: Bearer emx_… ``` | Scope | Allows | |---|---| | `mail:read` | reading mailboxes, messages, attachments, search, changes | | `mail:write` | flags, moves, deletions, sending, webhooks; includes reading | | `admin` | the administration of the organisation (administrators only) | A token never opens another person's mailbox. Tokens, sessions, app passwords and the operator's pages are managed in the web client only. 600 requests per minute per token; beyond that `429` with `Retry-After`. Every answer carries `X-Request-Id`. ## Me `GET /api/me` returns the person, their `accounts` (own mailbox and the shared ones they may open, each with `rights`), `sendFrom`, `limits`, `orgs` (for people in several organisations), `prefs` and `mailHost`. `PATCH /api/me/prefs` merges a JSON object into the preferences; `null` removes a key. `POST /api/me/switch` `{"tenantId": "…"}` changes the organisation of the session (sessions only). ## Mailboxes `GET /api/accounts/{account}/mailboxes` ```json {"mailboxes": [{"id": "…", "name": "INBOX", "role": "inbox", "total": 120, "unseen": 4, "bytes": 8123456, "modseq": 812, "uidValidity": 1266595615}]} ``` `role` is `inbox`, `drafts`, `sent`, `archive`, `junk`, `trash`, `snoozed`, `screener`, or empty for a folder the person made. `POST /api/accounts/{account}/mailboxes` `{"name": "Projekte", "parentId": null}` ## Messages `GET /api/accounts/{account}/messages?mailbox={id}&limit=50&cursor=…` lists newest first; the answer carries `cursor` for the next page, empty at the end. ```json {"messages": [{ "id": "…", "mailboxId": "…", "threadId": "…", "receivedAt": "2026-09-24T07:41:12Z", "sentAt": "2026-09-24T07:41:03Z", "subject": "Offerte Website-Relaunch", "from": {"name": "Anna Beispiel", "address": "anna@beispiel-ag.ch"}, "to": ["samuel@elchi.dev"], "cc": [], "snippet": "Danke für die Offerte …", "hasAttachments": true, "keywords": ["$seen"], "size": 48213, "dmarc": "pass", "modseq": 811 }], "cursor": "…"} ``` | Call | Returns | |---|---| | `GET …/messages/{id}` | the message with `text`, `html` (sanitised, remote images in `data-src`, `remoteImages` counts them), `attachments` with `part` paths, `replyTo`, `messageId`, `inReplyTo`, `references` | | `GET …/messages/{id}/parts/{part}` | one part with its content type; `?download=1` forces a download | | `GET …/messages/{id}/raw` | the message as received, `message/rfc822` | | `GET …/threads/{threadId}` | the conversation, oldest first | | `GET …/search?q=offerte+2026` | subject, people and text; every word must occur, prefixes match | A message in a [sealed](#sealed-mailboxes) mailbox comes back as `ciphertext` (base64 age), and its parts answer `409 sealed`. ### Changes `GET /api/accounts/{account}/changes?since={modseq}` ```json {"updated": [ …messages… ], "destroyed": ["…"], "modseq": 812, "hasMore": false} ``` Everything that changed after `since`. Keep the returned `modseq` and ask again. `410` with code `reload` means the state is too old: load the lists again. ### Live updates `GET /api/accounts/{account}/events` is a server-sent event stream. Each `change` event carries `{"modseq": N}`; ask `/changes?since=` with the modseq you had. A keepalive comment comes every 25 seconds; a stream ends after an hour and is reconnected by the client. ### Writing | Call | Body | |---|---| | `POST …/messages/keywords` | `{"ids": ["…"], "add": ["$seen"], "remove": ["$flagged"]}` | | `POST …/messages/move` | `{"ids": ["…"], "to": "{mailboxId}"}` | | `POST …/messages/delete` | `{"ids": ["…"]}`, for good; the client moves to Trash first | | `POST …/messages/snooze` | `{"ids": ["…"], "until": "2026-09-28T07:00:00Z"}`; at that time the messages return to the inbox, on top and unread | Keywords follow JMAP: `$seen`, `$flagged`, `$answered`, `$draft`, `$forwarded`, and your own without `$`. IMAP flags map onto the same keywords, so a message read on the phone is read here. ### The screener With `prefs.screener` true, mail from outside by a sender the person never wrote to waits in the Screener folder. Decisions are contacts: | Call | Does | |---|---| | `GET …/contacts?state=approved\|blocked` | lists them | | `POST …/contacts` `{"address": "…", "state": "approved", "name": "…"}` | records the decision and moves that sender's waiting messages to the inbox, or to Trash when blocked | | `DELETE …/contacts/{address}` | forgets it | Sending to somebody records them as approved. Colleagues in the same organisation are never screened; blocked senders go to Trash whether the screener is on or not. ## Sending `POST /api/send` ```json { "from": "contact@elchi.dev", "to": "Anna Beispiel , marco@example.ch", "cc": "", "bcc": "", "subject": "Re: Offerte", "text": "Gerne.", "html": "

Gerne.

", "inReplyTo": "abc@beispiel-ag.ch", "references": ["abc@beispiel-ag.ch"], "attachments": [{"filename": "Offerte.pdf", "contentType": "application/pdf", "data": ""}] } ``` `from` must be an address in `sendFrom`. The message is signed, filed in Sent, delivered locally or queued, the same path a mail app takes. Send `Idempotency-Key` to make a retry safe: the same key from the same person within a day returns the first answer with `Idempotent-Replayed: true`. Answers: `200 {"sent": true, "recipients": 2}`, `403 not_permitted`, `400 unknown_recipient`, `413 too_large`, `429 daily_limit` or `429 monthly_limit`. ## Webhooks See [Webhooks](/emx-webhooks). `GET /api/me/webhooks`, `POST /api/accounts/{account}/webhooks`, `DELETE /api/webhooks/{id}`, `POST /api/webhooks/{id}/test`, `POST /api/webhooks/{id}/enable`, `GET /api/webhooks/{id}/deliveries`. ## Administration (scope `admin`) `{tenant}` is `mine`; operators may name a tenant ID. | Method and path | Does | |---|---| | `GET /api/admin/{tenant}` | plan, usage, domains, billing | | `GET /api/admin/{tenant}/people` | people, shared mailboxes and groups | | `POST /api/admin/{tenant}/people` | add a person; answers with an invitation link | | `POST /api/admin/{tenant}/people/{id}/role` | `{"role": "admin"}` | | `POST /api/admin/{tenant}/people/{id}/suspend`, `restore` | sign-in, sending and receiving off or on | | `POST /api/admin/{tenant}/people/{id}/invite` | a new invitation link | | `POST /api/admin/{tenant}/people/{id}/addresses` | `{"address": "…", "primary": false}` | | `DELETE /api/admin/{tenant}/addresses/{address}` | remove an alias | | `POST /api/admin/{tenant}/mailboxes` | `{"kind": "shared", "name": "Kontakt", "address": "contact@…"}` | | `GET, PUT, DELETE /api/admin/{tenant}/mailboxes/{id}/members[/{member}]` | members and rights (`read`, `write`, `delete`, `send_as`, `send_on_behalf`, `manage`) | | `GET, PUT /api/admin/{tenant}/rules`, `DELETE …/rules/{id}` | membership rules by role | | `GET, POST /api/admin/{tenant}/domains` | domains; a new one is unverified | | `POST /api/admin/{tenant}/domains/{id}/verify` | check the `_emx-challenge` TXT record | | `GET /api/admin/{tenant}/domains/{id}/dns?dmarc=none` | every record the domain needs, each checked in DNS | | `POST /api/admin/{tenant}/domains/{id}/activate` | accept the domain as set up | | `POST /api/admin/{tenant}/domains/{id}/outbound` | `{"outbound": "resend", "resendKey": "re_…"}` or `emx` | | `POST /api/admin/{tenant}/domains/{id}/keys/rotate`, `keys/activate` | DKIM keys | | `PUT, DELETE /api/admin/{tenant}/logo` | the organisation's logo in the client (PNG, JPEG, SVG or WebP, 256 KB) | | `GET /api/admin/{tenant}/audit?before=…` | the audit trail, 100 at a time | | `POST /api/admin/{tenant}/billing/checkout`, `portal` | Stripe pages | ## Sealed mailboxes Where the organisation allows it (by default only an organisation of one person does; its owners can change that), a person can seal their mailbox: from then on every message, its subject, sender and recipients included, is stored as `age` ciphertext to the person's key, which the server keeps only wrapped to their recovery code and to a passkey or a passphrase. Neither the organisation nor Elchi can open it. Mail that was in the mailbox before it was sealed stays as it was. Our servers see a message in clear only while it is being received or sent, and mail to and from other servers travels as ordinary mail. For the API this means: - messages come back as `ciphertext`, which holds the subject and the people as well; what stays readable is the time a message arrived, its size, its mailbox and its keywords, and `snippet` is empty; - parts, search on the server and IMAP are off; - `message.received` webhooks leave out the subject and the addresses; - the mailbox sends no automatic replies and forwards nothing, and of its rules only those on the size of a message apply. If the passkey, the passphrase and the recovery code are all lost, the sealed mail is lost with them: nobody can recover it. The switch is in the settings, behind a page that lists these consequences. ## Versioning The API is unversioned while EMX is with its first customers. Fields are added, never renamed or removed, without notice. A breaking change gets `/api/v2` and a year of both. --- # EMX error codes Every error is ```json {"error": {"code": "not_permitted", "message": "…", "requestId": "…", "docs": "https://docs.elchi.dev/emx-errors#not-permitted"}} ``` `code` is stable and what your program should switch on. `message` is for a person and may change. `requestId` is what to quote when you write to us; it is also in the `X-Request-Id` header of every answer. ## Requests ### bad_request `400`. The body could not be read, a field is missing or malformed, an ID is not a UUID, a name is not allowed. The message says which. ### not_found `404`. No such thing, or one you may not see: whether it exists is private, so both answer the same. ### too_large `413`. A message, attachment or upload beyond the limit; `limits.maxMessageBytes` in `/api/me` says what it is. ### rate_limited `429`. More than 600 requests in a minute with one token. `Retry-After` says how long to wait. ### server_error `500`. Our fault. The request id is in the answer; the error is in our log with it. ### unavailable `503`, sometimes `501`. A part is not configured on this server: sending, live updates, billing, DNS checks. Not a retry case. ## Authentication and rights ### bad_token `401`. The token is unknown, revoked or expired. Make a new one. ### signed_out `401`. The session ended or was revoked; the web client signs in again. ### scope `403`. The token lacks the scope the call needs. ### forbidden `403`. Allowed in principle, not for you: an administrator's call by a member, an operator's call by an administrator. ### session_only `403`. Tokens, app passwords, profiles and sessions are managed in the web client, not with a token. ### cross_origin `403`. A browser request from another site. Programs send a bearer token and are never affected. ### no_identity_provider `503`. Sign-in through EAuth is not configured on this server. ## Mail ### reload `410` on `/changes`. The changes since that state are no longer known; load the mailbox again and continue from the `modseq` you get. ### sealed `409`. The mailbox is sealed: this part or body is ciphertext the browser opens, not the server. ### not_sealed `404`. Sealed-mailbox calls on a mailbox that is not sealed. ### name_taken `409`. A folder with that name exists at that place. ## Sending ### not_permitted `403`. `from` is not one of your `sendFrom` addresses, or the right to send as it was removed. ### unknown_recipient `400`. A recipient at one of our domains does not exist. Mail to other domains is accepted and bounces later if need be. ### refused `400`. The message was refused for what it is: a malformed address, a header that cannot be sent, too many recipients. The message says which. ### daily_limit `429`. Your recipients for today are used up; the count resets at midnight Swiss time. The plan sets the limit: 200 on Private, 1,000 on Team. ### monthly_limit `429`. The organisation's recipients for this month are used up. The plan says how many; Team can raise it. ### try_later `503`. The queue could not take the message right now. Try again in a minute; with the same `Idempotency-Key`, a retry cannot send twice. ## Webhooks ### too_many `409`. Twenty webhooks on the account already. ## Administration ### taken `409`. The address belongs to somebody already. ### signed_in `409`. The person has signed in already; an invitation cannot be made for them again. ### last_owner `409`. An organisation keeps at least one owner. ### plan_limit `402`. The plan does not allow another mailbox, domain or person. Change the plan under billing. ### not_verified `409`. The domain's `_emx-challenge` record was not found. DNS can take a few minutes. ### no_subscription `404`. The organisation has no subscription yet; checkout comes first. ### billing_error `502`. Stripe did not answer. Try again in a moment. ### bad_signature `400` or `401`. A signed request (Stripe's webhook, the Elchi console's internal API) did not verify. Check the secret and the clock. --- # EMX compared Written to be useful rather than flattering. The figures are from each provider's own pages where they render prices, and from dated third-party price lists where they do not (marked as such); everything was read on 28 September 2026, Hostpoint and Proton again on 6 October 2026. Prices change, so check the provider's page before you decide. The last section says where you should pick something else. ## At a glance | | EMX | Infomaniak | Hostpoint | Proton | Fastmail | Google Workspace | Microsoft 365 | mailbox.org | Tuta | Migadu | |---|---|---|---|---|---|---|---|---|---|---| | Per mailbox and month | CHF 4 (Team), CHF 3 with your own Resend key, no VAT charged | CHF 1.76 (kSuite) to 2.29 (Mail Service) | CHF 2 to 3.50 in the first year, then CHF 3 to 7, VAT included | USD 6.99 to 19.99 (annual) | USD 3 to 9 (third-party list) | CHF 6 to 18 (Swiss resellers) | CHF 5.67 to 25.92 (official, annual) | EUR 1 to 12 | EUR 6 to 12 | USD 19 to 990 a year, flat, by sending volume | | Mail lives in | Switzerland and the EU: messages in Geneva, the database in Germany, copies in the Netherlands and France | Switzerland | Switzerland | Switzerland | EU or US, your choice | Google's regions | Swiss regions available | Germany | Germany | not stated | | Company | Krauss Software (Elchi Studios), a sole proprietorship, Oberägeri ZG | Infomaniak, Geneva, foundation-owned | Hostpoint, Rapperswil-Jona | Proton AG, Geneva | Australia | US | US | Germany | Germany | Switzerland | | IMAP and SMTP | yes | yes | yes | SMTP for sending on paid plans with your own domain; IMAP only through Bridge on a desktop | yes, and JMAP | yes | yes | yes | no | yes | | API | the web client's own API, documented | platform API; mail detail not published | not published | no public mail API | JMAP | Gmail API | Graph | "API for professionals" | no | no | | Leaving | IMAP export and the API | IMAP | IMAP | Bridge on a desktop | IMAP, JMAP | IMAP, Takeout | IMAP, PST | IMAP | only the app's export | IMAP | | Office suite, video | no | kDrive, OnlyOffice, kMeet | drive, document editing | Drive, Docs, Meet | no | Drive, Docs, Meet | Office, Teams | Office, Meet | no | no | Sources: [Infomaniak](https://www.infomaniak.com/en/ksuite/service-mail/prices), [Hostpoint](https://www.hostpoint.ch/en/email/), [Proton plans](https://proton.me/business/plans) with prices from [a review of July 2026](https://ifeeltech.com/blog/proton-business-suite-review), [Proton on SMTP submission](https://proton.me/support/smtp-submission), [Fastmail](https://www.fastmail.com/pricing/) with prices from [Tekpon](https://tekpon.com/software/fastmail/pricing/), [Mixel IT on Google and Microsoft in CHF](https://www.mixel.ch/de/insights/microsoft-365-vs-google-workspace/), [Microsoft de-ch](https://www.microsoft.com/de-ch/microsoft-365/business/compare-all-microsoft-365-business-products), [mailbox.org](https://mailbox.org/en/business), [Tuta](https://tuta.com/business) with prices from [Atriomail](https://atriomail.com/tuta-email-for-msps/), [Migadu](https://www.migadu.com/pricing/). ## What the price buys At CHF 4 a mailbox EMX is not the cheapest Swiss mail; Infomaniak and Hostpoint are, and both bundle a drive and document editing; Hostpoint also includes a calendar and an address book, which EMX does not have. What EMX charges for is the part a developer or an agency needs and the others do not sell: the API the web client itself uses, signed webhooks when a message arrives or a delivery fails, and mailboxes that can be sealed so that only the person's own devices can open them. If your people want Office and Teams, the comparison is over before it starts: take Microsoft 365, in the Swiss region. ## Where the money and the trouble go The complaints in public reviews and forums cluster on four things. - **Protocol lock-in.** Proton lets a printer, a web form or another program send by SMTP on its paid plans with your own domain, but a mail app reads Proton mail only through Bridge, a desktop program, so a phone's own mail app cannot; Tuta offers no IMAP, SMTP or POP on any plan, and no IMAP-based tool can migrate out. EMX is plain IMAP and SMTP, and the API is the second door. - **Price changes.** Fastmail raised its plans in 2025 and 2026; Proton repriced its business plans in 2024; mailbox.org tripled its domain fee in 2021. EMX's price is on this site and changes only with 30 days' notice ([terms](https://elchi.dev/en/legal/emx-terms), 3.6). - **Limits that bite.** Migadu's flat price caps outbound mail per day. EMX limits sending per person and day and per organisation and month (30,000 recipients a month on Team), and every limit is [published](/emx-abuse#the-limits), not discovered. - **Support.** Fastmail at two days and more in reports, cyon and Infomaniak within business hours on the standard plans. EMX's support is a conversation with a person in the console or by mail to contact@elchi.dev; we aim to answer within a working day. There is no telephone support. Sources: [CyberInsider on Proton](https://cyberinsider.com/email/reviews/protonmail/), [Atriomail on Tuta](https://atriomail.com/tuta-email-for-msps/), [Tekpon on Fastmail's prices](https://tekpon.com/software/fastmail/pricing/), [Proton's 2024 price post](https://proton.me/blog/proton-business-updates), [ComputerBase on mailbox.org](https://www.computerbase.de/forum/threads/mailbox-org-erfahrungen.2034168/), [TrekMail on Migadu](https://trekmail.net/blog/migadu-alternative). ## Where your mail is, and whose law applies A US company answers to US law wherever the servers stand (the CLOUD Act). Microsoft offers Swiss regions for Exchange and Google offers EU regions; the company behind them does not change. Infomaniak, Hostpoint and Proton are Swiss companies, and EMX is run by a Swiss sole proprietorship; mailbox.org, Posteo and Tuta are German. EMX's database and copies are in Germany, the Netherlands and France, where those states' authorities can reach them through the provider under their own law. Whether that matters for you is your question; the table and the [terms](https://elchi.dev/en/legal/emx-terms), Annex A, are there so you can answer it. ## Where you should pick something else **You need a calendar, contacts or Exchange ActiveSync.** EMX has none of them, and no mobile app of its own. Hostpoint and Infomaniak include a calendar and contacts; Microsoft 365 has all three. **You want Office, a drive and video calls in the same subscription.** Microsoft 365 or Google Workspace, or Infomaniak's kSuite if it must stay Swiss. EMX is mail. **You want the cheapest Swiss mailbox.** Infomaniak at CHF 1.76 to 2.29 with unlimited storage, or Hostpoint. **You want end-to-end encryption between mailboxes by default.** Proton or Tuta encrypt between their own users without a setup. EMX seals a mailbox for the person's own devices, which is a different promise: our servers see a message in clear while it is received or sent, and mail to and from other servers travels as ordinary mail. **You want a decade of operation behind your provider.** Infomaniak, Hostpoint and cyon have it. EMX is young, and says so rather than hiding it. --- # EMX acceptable use and abuse Mail is only useful while the servers sending it are trusted. One customer sending spam can put our servers on block lists, and then every other customer's mail arrives late or not at all. So the rules are strict, and a report of abuse is acted on quickly. ## What EMX may not be used for The [terms](https://elchi.dev/en/legal/emx-terms), section 6, are binding; in short, EMX may not be used to send: - unsolicited mass advertising, or mail to addresses that were bought, harvested or otherwise collected without consent; - malware, or links to it; - phishing, forged senders, or mail that pretends to come from another organisation; - content that is unlawful to send or to hold, such as depictions of sexual abuse of children, incitement to violence or hatred, or material that infringes the rights of others; - harassment and threats. EMX may also not be used as a relay for other people's mail, resold without an agreement, or probed for weaknesses without our written permission. Newsletters are fine to people who asked for them, with a working unsubscribe link and within the limits below. EMX is not a mass mailing service. ## The limits | Limit | Private | Team | |---|---|---| | Recipients outside EMX per person and hour | 300 | 300 | | Recipients per person and calendar day (counted from midnight Swiss time) | 200 | 1,000 | | Recipients per organisation and calendar month | 1,000 | 30,000 | | Size of one message, attachments included | 50 MB | 50 MB | | Recipients of one message | 100 | 100 | A send over a limit is refused, over the daily or the monthly one with [`daily_limit`](/emx-errors#daily-limit) or [`monthly_limit`](/emx-errors#monthly-limit); nothing is queued to go out later. Receiving is limited only by the size of a message and the mailbox's storage: while a mailbox is full, sending servers are asked to try again later. What our people send is filtered like what they receive: no programs as attachments, and no spam. Going over a limit refuses only that message. A person whose messages are refused again and again as spam or malware is stopped automatically: their mail is refused until an administrator of the organisation, or we, lift the stop. The administrators see the stop and its reason in the administration. ## Reporting abuse Write to **abuse@emxmail.ch**. A report we can act on contains: - the message itself as an attachment (an `.eml` file, or "forward as attachment"), so that its headers are intact; a forwarded copy or a screenshot loses the headers that show where the message came from; - for anything other than a message, such as a phishing page linked from one, the address and the time you saw it. Spam that reaches an EMX mailbox from elsewhere is not abuse of EMX: press **Spam** in the web client, or move it to the junk folder in your mail app. A security problem in EMX itself goes to security@elchi.dev. ## What happens to a report 1. **We read it on the next working day at the latest.** We check the headers first. Mail sent through EMX carries our servers in its `Received` lines and a DKIM signature of the sender's domain made by EMX; mail that only claims to come from an EMX customer does not, and we tell you so. 2. **We find who sent it** and look at the organisation's sending: how much, to whom, and whether the account was signed into from somewhere unusual. 3. **We act, starting with the mildest step that works.** Usually that is a warning to the organisation's administrators with a deadline. Where mail keeps going out, we stop the sending of the person or mailbox concerned, or lower their daily limit, then stop the organisation's. Suspension stops mail from going out; it never deletes anything. 4. **We act at once, without a warning first,** when the mail is malware or phishing, when an account is evidently compromised and sending, or when our servers are being listed on block lists because of it. The administrators are told within one working day, with the reason. 5. **Repeated or serious abuse ends the contract** (terms, 4.5). We answer the person who reported when there is something to say: that the mail did not come from EMX, or that we acted on it. We do not tell a reporter who our customer is. ## What we cannot do - **Pull back mail that was delivered.** A message in someone's mailbox at another provider is out of our reach. - **Read a sealed mailbox.** We act on what the reporter sends us and on what our own logs show, not on the content of a sealed mailbox. - **Decide disputes about content.** Whether a message is defamatory or infringes a right is for a court to decide. We act on a court's order, or where a breach of the law is evident. ## Related - [Requests from authorities](/emx-authorities): how we answer the police, prosecutors and courts, and what data exists. - The [terms](https://elchi.dev/en/legal/emx-terms), sections 6 and 7, are the binding text for everything on this page. --- # EMX and requests from authorities Your mail is your business. We hand it to an authority only where Swiss law obliges us to, and we tell you when we do, unless the law forbids it. This page says how that works and, just as important, what data exists to be handed over at all. ## Who answers, under which law EMX is operated by Krauss Software, the sole proprietorship of Samuel Krauss in Oberägeri ZG, under Swiss law. Requests go in writing to **legal@elchi.dev**. Under the Federal Act on the Surveillance of Post and Telecommunications (BÜPF, SR 780.1), EMX is a provider of derived communication services (Art. 2 lit. c BÜPF): a service that builds on telecommunications and lets people communicate, as opposed to a telecommunications provider such as a network operator. For such a provider there is no registration with the Post and Telecommunications Surveillance Service (Dienst ÜPF). Further duties to give information or to carry out surveillance apply only once the Dienst ÜPF declares a provider subject to them, which it does on certain sizes: for example 100 requests for information, or surveillance orders on 10 different targets, in twelve months, or CHF 100 million of turnover in Switzerland in two consecutive years together with 5,000 subscribers (Art. 22 and 52 of the ordinance, VÜPF, SR 780.11). EMX is far from these. What the law requires of EMX, then, is this: - to tolerate a surveillance ordered under the BÜPF against a person who uses EMX, to give the Dienst ÜPF access to its installations for it, and to give the information needed (Art. 27 para. 1 BÜPF); - to deliver, on request, the metadata of the target's communications that it has, such as who wrote to whom and when (Art. 27 para. 2 BÜPF); - to deliver the information it has that identifies the author of an offence committed over the internet (Art. 22 para. 3 BÜPF). None of this obliges EMX to collect or keep data it does not otherwise have, or to decrypt what it cannot decrypt. ## How we handle a request 1. **We check who is asking and through which channel.** A request by telephone, or by mail we cannot trace to the authority, is answered only with the address above. 2. **We check the legal basis and the scope.** We hand over only what an order of a competent Swiss authority covers, and nothing beyond it. Where an order seems to us unlawful or too broad, we ask the authority to narrow it and use the legal remedies open to us. 3. **We tell the customer** without delay, unless the law or the order forbids it. Where it is forbidden for a time, we tell the customer once that time has passed. 4. **We keep a record** of every request, what it asked for and what we handed over. A lawyer, a company or anyone else who is not an authority acting under the law gets no data about a customer from us without an order of a Swiss court or the customer's consent. ## Requests from abroad We do not answer foreign authorities directly. A foreign authority that wants data from EMX must go through international mutual legal assistance with Switzerland, and its request then reaches us, if at all, as an order of a Swiss authority under Swiss law. One limit should be said plainly. EMX runs on servers rented in Switzerland and in the European Union (the [terms](https://elchi.dev/en/legal/emx-terms), Annex A, list them). An authority of Germany, the Netherlands, France or Bulgaria can, under its own law, reach data through the provider in its country. The message bodies held there are encrypted with a key that is not kept with the stored data. The database, with the senders, recipients and subjects of mail that is not sealed, is not encrypted in that way. ## What data exists | Data | Kept | |---|---| | The organisation, its people, their addresses and roles | while the contract runs, then 30 days | | Mail in the mailboxes, with sender, recipients, subject and times | until a person deletes it, or the mailbox is deleted | | Mail filed as junk, or held in the organisation's quarantine | the organisation's retention period, 30 days unless it set another | | Mail of a person the customer removed | 30 days, unless an administrator turned the mailbox into a shared mailbox or handed its mail to a colleague | | Records of outgoing mail: sender, recipient, time, outcome | a week after delivery | | Security and access logs: sign-ins, administrative actions, the IP address they came from | the full IP address 90 days, then only its network (IPv4 /24, IPv6 /48) | | Sessions of the web client, with IP address and browser | until they end, at most 90 days | | Deliveries of the webhooks a customer set up, with what each carried | 30 days | | Daily counts of recipients per person, for the limits | 30 days | | Invoices and billing details | ten years, as the law requires (Art. 958f OR) | The operating logs of the servers also record connections to the mail ports with their IP address, and the sender of each message sent; they are used only to run the service and to handle abuse. EMX keeps no other record of who wrote to whom. **Sealed mailboxes.** A sealed message, its subject, its sender and its recipients included, is stored encrypted to the person's key, which we keep only encrypted to their recovery code and to a passkey or a passphrase we never see. We cannot decrypt it, and no order can make us hand over what we cannot read. Mail that was in the mailbox before it was sealed is not sealed. What we have of a sealed mailbox is the encrypted messages, the time each arrived, its size and its folder, and for mail sent to other servers the record of its delivery, kept a week. ## Transparency Each year we publish on this page how many requests we received, from which kind of authority, and how many we answered with data, as far as the law allows us to say. ## Related - [Acceptable use and abuse](/emx-abuse): how to report spam, phishing or malware sent from EMX. - The [terms](https://elchi.dev/en/legal/emx-terms), Annex A, A.11, are the binding text for requests from authorities. --- # Web standards The technical standard every Elchi Studios site is built to, with the tools named so each claim can be checked. --- # How we build a website Every site we deliver is built to the same standard. This page states it plainly so a client can check afterwards whether they got it, and so a developer can see what they would be taking over. ## What is measured Three numbers are checked before a site goes live, with the tool named so the result can be reproduced: | What | Target | Measured with | |---|---|---| | Largest Contentful Paint | under 1.5 s on a throttled 4G connection | Google PageSpeed Insights | | Lighthouse performance | 90 or above on mobile | Lighthouse CI, median of five runs | | Cumulative Layout Shift | under 0.05 | the same run | A median of five runs rather than a single result, because Lighthouse varies by ten points or more between runs and one flattering screenshot proves nothing. ## The stack **Server-rendered HTML from Go.** No client-side framework and no hydration step. The markup that arrives is the finished page, which is why the first paint happens before any JavaScript has run, and why a crawler and an answer engine see exactly what a visitor sees. **The stylesheet is inlined.** It is small enough that a separate request would cost more than it saves, so the critical path is one request. **JavaScript is optional.** Every form works without it. Where script is used, it is deferred and enhances something that already functions. ## Images Uploads are converted to AVIF and WebP at six widths and served from a separate asset host. The browser picks the smallest format it understands through a `picture` element; there is no user-agent sniffing. A real example from a site we built: a 324 kB source photograph becomes a 16 kB AVIF at 960 pixels wide, five per cent of the original. Every image carries its dimensions in the markup and a BlurHash placeholder, which is around 30 characters and travels inside the HTML. That removes the blank-then-pop effect without an extra request, and keeps layout shift at zero. ## Analytics without a cookie banner Visitor statistics run on the client's own server. A visitor is identified by ``` SHA-256(ip + "|" + date + "|" + user-agent hash) ``` The raw IP address is never stored and the identifier changes every 24 hours, so nobody can be followed across days. No third party is involved and no persistent identifier exists, which is why no consent banner is required under the ePrivacy Directive or the Swiss revDSG. The practical effect is that a visitor sees the site immediately instead of a dialog, which is worth more than the analytics. ## Content the client controls Text, images, prices and legal documents are edited in an admin area without asking us. Legal documents are versioned and append-only: publishing a new version inserts a row rather than replacing one, so the exact text a customer agreed to on a given date stays reproducible. ## Search and answer engines Structured data as JSON-LD, one graph with cross-referenced identifiers rather than several disconnected blocks. Correct `hreflang` between language versions, including `x-default`, so two translations are not read as duplicates competing with each other. Plus an `llms.txt`: a plain-language summary an assistant can read without crawling the site. Being cited when somebody asks an assistant for a supplier in a particular region is a channel that appears in no analytics dashboard and still produces enquiries. ## What the client owns The site, the backend, the content and the data. The export includes everything, in formats another developer can read. We think being able to leave is a feature. A supplier who keeps a client by making departure painful has stopped competing on the work.