<!-- https://docs.elchi.dev/security | EAuth | Elchi Docs -->

# 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.

**The console and the dashboard ask for a second factor.** Whoever can
change an application, its secrets or its API keys signs in with an
authenticator app as well as a password or passkey. An account without one
is taken to set it up first, and comes back to the console when it is done.

**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`.
