People
Signing in
The ways a person proves who they are, and the limits around them. Everything here is in auth-service, whose pages the web app sends people to.
Its routes are in the API reference: auth-service.
The ways in
| Way | Page | Then |
|---|---|---|
| Password | /oauth/login |
Two-factor, if the account has it |
| Passkey | /oauth/login, the passkey button |
Nothing more: a passkey is both factors |
| Emailed code | /oauth/login/code |
Two-factor, if the account has it |
All three end the same way, and all three apply the same rules to the account once it has been proved: a deleted account is offered its restore, an archived or disabled one is refused.
Signing in with a code
- The person follows Sign in with a code instead and enters their email address or username.
- If the account exists, it is emailed a 6-digit code. The code is in the subject as well, so it can be read from a notification.
- They type the code on the page that is waiting for it.
The email carries no link. The page that wants the code is already open, and a link is what mail scanners follow.
The pages never say whether an account exists. An address with no account gets the same pages and wording, and the same five tries, as a real one. It is just never sent anything. A username is never answered with its address.
A code sent to an account whose address was never confirmed confirms it: the code arriving and coming back is what the confirmation link proves.
What a code can and cannot do
- It replaces the password, not the second factor. An account with two-factor turned on is still asked for its authenticator code.
- It works once, for 10 minutes, and only in the browser that asked for it: the browser's session holds which code it is waiting for.
- A new code retires the old one.
- It is checked against the account as it is when the code is typed, not when it was sent.
- A code is issued for one purpose and refused for any other.
Limits
| What | Limit | Then |
|---|---|---|
| Tries at one code | 5 | The code is dead; ask for another |
| Codes requested for one address or username | 5 an hour | Nothing more is sent. The pages look the same |
| Codes requested from one network address | 20 an hour | The same |
| Password tries for one address or username | 10 in 15 minutes | Refused until the time has passed |
| Password tries from one network address | 50 in 15 minutes | The same |
Something that is not six digits is not counted as a try. Every limit is counted before the account is looked up, so reaching one says nothing about whether the account exists. A successful sign-in clears that address's count.
Somebody guessing at an account's password can use up its ten tries and lock password sign-in for a quarter of an hour. A code or a passkey still works.
The counts are kept in Redis. If Redis cannot be reached they are not enforced, and the log says so.
The sign-in forms carry a token tied to the browser's session, so another site cannot submit them on somebody's behalf.
How codes are kept
The six digits are never stored. The tokens row holds an HMAC of them,
keyed with a value derived from SECURE_COOKIE_KEY and salted with the
token's own random identifier. Reading the database does not reveal a code,
and trying all million possibilities against it is not possible without the
key. Changing SECURE_COOKIE_KEY invalidates the codes outstanding at that
moment.
Counting a try and using a code up are each a single statement in the database, so guesses arriving at the same moment cannot share a try, and only one of two correct submissions signs in.
Resetting a forgotten password
- At
/password/forgotthe person enters their email address. - If it has an account, it is emailed a 6-digit code, valid for 15 minutes and five tries.
- They type the code on the page that is waiting for it, and only then are shown the form for a new password.
The code is used up when the password is set, so a mistake on the password form can be corrected without a new code. Asking for a new code retires the earlier one, including in a browser that had already entered it.
Setting the password this way removes every passkey and signs out every session, and the account is told by email. It does not sign the person in.
At most three codes an hour are sent to one address, and the pages do not say whether the address has an account.
One link remains: the set-password link for the first super administrator, which is read from a log with no page open to type a code into.
Changing an email address
Done from the app's settings, signed in. The address is what password reset delivers to, so moving it takes two proofs:
- It is you.
PATCH /email/changewith the new address needs a recent step-up (a passkey, or the password and two-factor). One step-up buys one request. - You hold the current inbox. A 6-digit code is mailed to the current
address, naming the new one. The person types it into the same settings
screen, which calls
POST /email/change/confirmwith{ "code": "…" }.
| Answer | Meaning |
|---|---|
| 204 | Changed. Every passkey is removed and every session is signed out, this one included. Both addresses are told |
| 400 | Not six digits. Nothing was counted |
| 409 | The new address was taken in the meantime |
| 410 | Nothing is pending, or the code expired (15 minutes), was replaced by a newer request, or ran out of its five tries |
| 422 | Wrong code. The body is { "remaining": n } |
The code is found by whose it is, never by its digits, so one person cannot use or wear out another's.
Adding text messages later
The flow has a step for choosing how the code is sent, which is skipped while
email is the only way. Text messages need a verified phone number on the
account, a sender, and sms added to the list of channels in
auth-service/cmd/api/handlers/oauth_login_code.go. The pages, the limits and
the storage do not change.
Where the code is
| What | Where |
|---|---|
| Signing in with a password, then two-factor | auth-service/cmd/api/handlers/oauth_login.go (AddCredentials, AddTotp) |
| Signing in with a passkey | auth-service/cmd/api/handlers/oauth_web_authn.go, table webauthn_credentials |
| Signing in with a code | auth-service/cmd/api/handlers/oauth_login_code.go (loginCodeChannels), oauth_login_code_test.go |
| Issuing and checking codes | auth-service/cmd/api/handlers/otp.go (issueCode, verifyCode, consumeCode), auth-service/data/repositories/tokens.go (SpendCodeAttempt, ConsumeCode), table tokens |
| How codes are kept | auth-service/data/entities/token.go (hashCode), auth-service/config/config.go (GetCodeKey); setting SECURE_COOKIE_KEY |
| Code lifetimes | auth-service/constants/token.go (TokenExpiryTimeOTP, TokenExpiryTimeCode) |
| Limits | auth-service/cmd/api/handlers/rate_limit.go (rateLimits) |
| The form token | auth-service/cmd/api/handlers/form_csrf.go |
| A deleted account offered its restore | auth-service/cmd/api/handlers/oauth_restore.go |
| Resetting a forgotten password | auth-service/cmd/api/handlers/password_reset_code.go, password_reset_code_test.go |
| Changing an email address | auth-service/cmd/api/handlers/email_change.go, email_change_test.go |
From anahitasocial/anahita-services, 2026-10-27.
Written by Claude, reviewed by Rastin Mehr · About this