Account lifecycle
Everything that happens to a Quota account between sign-up and deletion. Social sign-in, email verification, password reset and change, account deletion, and the audit trail the user can read themselves.
01 Social sign-in
Quota supports Google and GitHub as identity providers. Both follow the same shape: a /auth/<provider>/start endpoint redirects the user out, and a /auth/<provider>/callback endpoint receives them back, mints a session cookie, and redirects on. There is no JSON API surface to wire up — the browser does the round-trip.
Each provider is gated independently by configuration. If GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET (or the GitHub equivalents) are unset, the routes are not registered at all and the hosted login page hides the button. A deployment can ship one provider, the other, both, or neither.
Redirects the user to Google's OAuth consent screen. State is HMAC-signed so the round-trip survives without cookies.
| return_tostring | Optional path on this server to land on after a successful sign-in (e.g. an in-progress /oauth/authorize). Off-site URLs and protocol-relative paths are ignored; fallback is /dashboard. |
OAuth 2.0 redirect URI for Google. Verifies the id_token signature against Google's JWKS, checks iss and aud, resolves to (or creates) a Quota user, sets a session cookie, and 302s to return_to. You don't call this endpoint directly — Google does, after the user consents.
GitHub
Redirects the user to GitHub's OAuth authorize page. Takes the same optional return_to as the Google start endpoint.
Exchanges the code for an access token, fetches the verified primary email from api.github.com/user/emails, resolves to (or creates) a Quota user, and mints a session cookie. GitHub accounts without a verified primary email are rejected — no fallback to an unverified address.
Account-linking rules
Both providers share the same linking decision matrix, so the outcome of "sign in with Google" is predictable regardless of how the user's email is shaped on the Quota side.
- Email verified by the provider, no Quota account exists: a new account is created, marked as email-verified, and linked to the provider identity.
- Email verified, an OAuth-only Quota account exists with the same email: link the new provider identity onto the existing account.
- Email verified, a password-based Quota account exists with the same email: rejected with HTTP 403. Auto-linking a social identity onto an existing password account would let anyone who controls a Google inbox take over the account. The user must sign in with their password first and link the provider from account settings.
- Email not verified by the provider: rejected with HTTP 401. We never trust an unverified provider email as proof of identity.
02 Email verification
New accounts created by password registration start with email_verified = false. Accounts created via Google or GitHub start verified, because the provider already proved the address. Verification is a token issued to the user's session and consumed by clicking the link in the email (or POSTing the token back).
Tokens live for 24 hours and are single-use. Replaying a consumed token for an already-verified user is a 200, not a 400 — the endpoint is idempotent so a double-click on the email link doesn't look like a failure.
Generates a fresh verification token, persists its hash, and emails the link to the authenticated user's address. Requires session auth (Authorization: Bearer sess_... or the session cookie). If the user is already verified, returns { sent: false, already_verified: true } without burning a rate-limit slot.
Rate limit: 3 sends per hour per user, counted from the database. The limit survives restarts and load-balanced replicas.
curl -X POST https://api.usequota.ai/auth/send-verification \
-H "Authorization: Bearer sess_..."Marks the user's email as verified given a valid, unconsumed, unexpired token. The token is the value carried in the link inside the verification email.
| tokenstringrequired | The verification token from the email. |
{
"token": "v_8f3a..."
}Errors
| token_expired400 | Token is past its 24-hour TTL. Request a new one via /auth/send-verification. |
| invalid_token400 | Token is malformed, never existed, or was consumed and the account never verified. |
Browser-friendly variant of the POST. The link inside the verification email points here so a click in a webmail client just works — no JSON parsing required. Returns a small HTML page confirming the verification (or explaining why it failed).
| tokenquery stringrequired | The verification token from the email link. |
03 Password reset
The classic forgot-password flow. The user requests a reset email, clicks the link, and POSTs a new password back. On success, every active session and OAuth token for the account is revoked — anyone signed in elsewhere is signed out.
| emailstringrequired | The email address to send the reset link to. |
{
"email": "ada@example.com"
}Rate limit: 20 requests/minute per IP (coarse scripted-spam defense), plus 3 reset emails per hour per email address (DB-counted, enforced inside the handler). OAuth-only accounts with no password set never receive an email — there is nothing to reset.
Renders a small self-contained HTML form that POSTs the new password back. The link inside the reset email points here. The token in the query string is not validated on render — that is the POST's job, and rendering unconditionally avoids a tiny enumeration oracle.
| tokenquery stringrequired | The reset token from the email link. |
Consumes a single-use reset token and replaces the user's password. Token TTL is 1 hour from issuance.
| tokenstringrequired | The reset token from the email link. |
| new_passwordstringrequired | Between 8 and 128 characters. Same rule as registration. |
{
"token": "r_2c91...",
"new_password": "a fresh long passphrase"
}On success: the new password_hash is written, every active session is revoked, every active OAuth token issued to the user is revoked, and a password_changed notification email is sent. The user must sign in again on every device.
Errors
| invalid_token400 | Token is missing, malformed, never existed, or already consumed. |
| token_expired400 | Token is past its 1-hour TTL. Request a new one. |
| weak_password400 | new_password is shorter than 8 or longer than 128 characters. |
04 Password change
Authenticated rotation. The user is signed in, supplies their current password, and gets a new one. The current-password check is the second factor — a stolen session cookie alone is not enough to change the password.
Requires session auth (cookie or sess_... bearer token). API keys cannot call this endpoint — the credential being rotated is the password, and a long-lived API key is the wrong signal of intent.
| current_passwordstringrequired | The user's existing password, for re-authentication. |
| new_passwordstringrequired | Between 8 and 128 characters. Must differ from current_password. |
{
"current_password": "old passphrase",
"new_password": "fresher longer passphrase"
}On success: the new password_hash is written, every OTHER session for this user is revoked (the caller's own session is preserved so they don't have to re-login mid-flow), and a password_changed notification email is sent. OAuth tokens are NOT revoked by this endpoint — a password change is a credential rotation, not a global sign-out.
Errors
| invalid_password400 | current_password did not match the stored hash. Also returned for OAuth-only accounts that have no password to verify against. |
| weak_password400 | new_password is shorter than 8 or longer than 128 characters. |
| password_unchanged400 | new_password matches current_password — nothing to do, and rejecting avoids burning a rate-limit slot. |
| rate_limited429 | More than 5 attempts in the last 15 minutes. The limit counts attempts (right or wrong) so a stolen session can't brute-force the current password. |
05 Account deletion
When a user wants to leave, the answer is yes. The endpoint soft-deletes the account, anonymizes personal data, revokes every access path, and sends one confirmation email. Foreign-keyed audit data — ledger entries, OAuth client rows owned by the user — is preserved so spend records remain readable. The user's name, email, and avatar are not.
Requires session auth (cookie or sess_... bearer token). The body re-confirms the password as a second factor — a stolen session token alone cannot delete the account. API keys cannot call this endpoint.
| passwordstringrequired | The user's current password, for re-authentication. |
{
"password": "current passphrase"
}What happens on success
- A
account_deletednotification email is sent to the user's real address (sent first, so the email goes to the address they actually own — not the anonymized placeholder). users.deleted_atis set toNOW(). The row is not hard-deleted; foreign-keyed audit data still resolves.emailis rewritten todeleted-<id>@quota.deleted;nameandavatar_urlare set toNULL.password_hashis replaced with a fresh bcrypt hash over a 32-byte random string. Login is impossible by any path.- Every active session is revoked. Every active OAuth token issued to the user is revoked.
Errors
| invalid_password400 | password is missing, empty, or did not match the stored hash. Also returned for OAuth-only accounts with no password set — those accounts need a password set first via a future flow before they can be deleted from this endpoint. |
06 Auth event log
Every meaningful auth-relevant action — sign-in, sign-out, password change, OAuth grant, social link, account deletion — is recorded to the auth_events table. The user can read their own history through one cursor-paginated endpoint.
Returns the authenticated user's own events in reverse-chronological order. Requires session auth.
| cursorISO-8601 string | created_at of the last item from the previous page. Pass next_cursor from the previous response to fetch the next page. A bad cursor is forgiven — you get the first page back, not a 400. |
| limitinteger | Page size, clamped to 1..50. Defaults to 20. |
curl https://api.usequota.ai/account/auth-events?limit=10 \
-H "Authorization: Bearer sess_..."Privacy at write-time
Sensitive fields are bounded before insert, not at read-time, so rows in the database are already safe for self-display.
- IP: truncated to
/24(last octet zeroed). Granular enough for abuse signals, coarse enough that we don't store precise location data per user. - User agent: truncated to 100 characters. Legitimate UAs fit; longer values are silently cut off.
- Request bodies: never read by the recorder. Only
request.ipanduser-agentare inspected.
Event types
The event_type column is plain text — adding a new type does not require a migration. The set in active use today:
| signup | A user account was created (password or social). |
| login | A session token was issued (password or social, success path). |
| login_failed | Bad credentials. Counted toward fraud signals. |
| logout | The user explicitly signed out. |
| password_changed | Password rotated, via /auth/change-password or /auth/reset-password. |
| password_reset_requested | A reset email was issued for an existing account with a password. |
| password_reset_consumed | The reset token was used to set a new password. |
| email_verification_sent | A verification email was issued. |
| email_verified | The verification token was consumed and email_verified flipped to true. |
| oauth_authorized | The user consented at /oauth/authorize for a third-party app. |
| oauth_token_issued | An access token was minted at /oauth/token. |
| oauth_token_revoked | A token was revoked at /oauth/revoke (or by an admin path). |
| social_link_created | A Google or GitHub identity was linked to the account. |
| social_link_removed | A linked social identity was removed. |
| account_deleted | The user soft-deleted their account via DELETE /account. |