Quota
/ docs
Dashboard
Docs/Authentication/Account lifecycle

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.

Where this fits
Account creation and password login live on the Authentication page. Everything on this page is the surface around them — the flows a user touches once in a while, plus the data behind them.
sess_… is the developer session token

Endpoints on this page are authenticated with a session token (cookie set by sign-in, or Authorization: Bearer sess_…). Mint one by signing in via POST /auth/login or in the dashboard. It expires after 24 hours. See Tokens and actors for how it differs from API keys and OAuth tokens.

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.

Google

GET/auth/google/start

Redirects the user to Google's OAuth consent screen. State is HMAC-signed so the round-trip survives without cookies.

return_tostringOptional 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.
GET/auth/google/callback

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

GET/auth/github/start

Redirects the user to GitHub's OAuth authorize page. Takes the same optional return_to as the Google start endpoint.

GET/auth/github/callback

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.
Provider-side configuration
The redirect URI Quota sends to the provider is derived from ALLOWED_ORIGINS[0]. Whatever that resolves to (e.g. https://yourapp.com/auth/google/callback) MUST be registered on the provider side — Google Cloud Console for Google, Developer Settings → OAuth Apps for GitHub.

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.

POST/auth/send-verification

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_..."
POST/auth/verify-email

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.

tokenstringrequiredThe verification token from the email.
{
  "token": "v_8f3a..."
}

Errors

token_expired400Token is past its 24-hour TTL. Request a new one via /auth/send-verification.
invalid_token400Token is malformed, never existed, or was consumed and the account never verified.
GET/auth/verify-email

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

Anti-enumeration by design
/auth/forgot-password always returns 200 with the same response body, regardless of whether the email exists, whether it has a password set, or whether the user is rate-limited. Variable-time work (token issuance, email send) runs after the response is dispatched. You cannot probe for which addresses are registered.
POST/auth/forgot-password
emailstringrequiredThe 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.

GET/auth/reset-password

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 stringrequiredThe reset token from the email link.
POST/auth/reset-password

Consumes a single-use reset token and replaces the user's password. Token TTL is 1 hour from issuance.

tokenstringrequiredThe reset token from the email link.
new_passwordstringrequiredBetween 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_token400Token is missing, malformed, never existed, or already consumed.
token_expired400Token is past its 1-hour TTL. Request a new one.
weak_password400new_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.

POST/auth/change-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_passwordstringrequiredThe user's existing password, for re-authentication.
new_passwordstringrequiredBetween 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_password400current_password did not match the stored hash. Also returned for OAuth-only accounts that have no password to verify against.
weak_password400new_password is shorter than 8 or longer than 128 characters.
password_unchanged400new_password matches current_password — nothing to do, and rejecting avoids burning a rate-limit slot.
rate_limited429More 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.

DELETE/account

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.

passwordstringrequiredThe user's current password, for re-authentication.
{
  "password": "current passphrase"
}

What happens on success

  • A account_deleted notification 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_at is set to NOW(). The row is not hard-deleted; foreign-keyed audit data still resolves.
  • email is rewritten to deleted-<id>@quota.deleted; name and avatar_url are set to NULL.
  • password_hash is 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_password400password 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.
No undelete
Deletion is permanent from the user's perspective. The soft-delete preserves audit data for compliance and accounting, but there is no flow that resurrects the row, restores the email column, or lets the user sign in again. If they want to come back, they register a new account.

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.

GET/account/auth-events

Returns the authenticated user's own events in reverse-chronological order. Requires session auth.

cursorISO-8601 stringcreated_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.
limitintegerPage 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.ip and user-agent are 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:

signupA user account was created (password or social).
loginA session token was issued (password or social, success path).
login_failedBad credentials. Counted toward fraud signals.
logoutThe user explicitly signed out.
password_changedPassword rotated, via /auth/change-password or /auth/reset-password.
password_reset_requestedA reset email was issued for an existing account with a password.
password_reset_consumedThe reset token was used to set a new password.
email_verification_sentA verification email was issued.
email_verifiedThe verification token was consumed and email_verified flipped to true.
oauth_authorizedThe user consented at /oauth/authorize for a third-party app.
oauth_token_issuedAn access token was minted at /oauth/token.
oauth_token_revokedA token was revoked at /oauth/revoke (or by an admin path).
social_link_createdA Google or GitHub identity was linked to the account.
social_link_removedA linked social identity was removed.
account_deletedThe user soft-deleted their account via DELETE /account.