Skip to content

End-user auth

Authentication for your app’s users — not for the developers who administer the project. Those are two separate populations with separate tokens.

Everything lives under /api/v1/auth/. Unauthenticated POSTs are rate-limited to 10 per minute per IP, project and action, returning 429 RATE_LIMITED with a retry-after header.

Route Purpose
POST register Create a user (email, password, optional name).
POST login Password sign-in → tokens, or {mfaRequired, mfaToken}.
POST refresh Exchange a refresh token for a new access token.
POST logout Revoke the presented refresh token.
GET sessions · DELETE sessions/:id List and revoke sessions.
POST verify-email/request · POST verify-email Email verification.
POST password-reset/request · POST password-reset Password recovery.
POST mfa/verify Complete a login with a second factor.
POST mfa/totp/enroll · /activate · /disable Manage TOTP.

A short-lived access JWT (at most 15 minutes) plus a refresh token:

{ "sub": "user_123", "pid": "proj_abc", "role": "user", "perms": [...],
"aud": "project", "claims": { "plan": "pro" }, "iat": , "exp": }

Those claims are what a policy reads: sub becomes $auth.uid, role becomes $auth.role, pid becomes $auth.pid, and anything in claims is reachable as $auth.token.<name>.

Refresh tokens are single-use. Presenting one revokes the old session and mints a new one, so a stolen refresh token stops working the moment the real user refreshes.

Three audiences never interchange: project (your app’s users), control (platform users), and mfa (a 5-minute challenge token).

  • Passwords: Argon2id.
  • Refresh tokens, email-verification tokens, reset tokens and MFA backup codes: SHA-256 hashes only. The raw value is returned exactly once and never persisted.
  • passwordHash and mfaSecrets are stripped from every response.

Deliberate design, so an attacker cannot use your auth endpoints to discover who has an account:

  • An unknown email on login still runs a dummy password verify, so the timing matches.
  • password-reset/request and verify-email/request always return 204, whether or not the account exists.

Password login fails with EMAIL_NOT_VERIFIED (403) when the address is unverified — but only if a mailer is configured and requireVerifiedEmail is not disabled. No mailer means no gate, which avoids a permanent lockout on a deployment that cannot send email.

OAuth login is exempt: the provider already verified the address.

TOTP is RFC 6238 with a ±1 step window and replay protection — a step at or below the last accepted one is rejected, so a code cannot be replayed inside its own window.

The flow:

const res = await client.auth.login(email, password);
if (res.mfaRequired) {
await client.auth.verifyMfa(code); // TOTP or a backup code
}

Enrollment:

const { secret, otpauthUrl } = await client.auth.enrollTotp(); // render as a QR code
const { backupCodes } = await client.auth.activateTotp(code); // shown exactly once

Backup codes are single-use. Disabling TOTP requires a valid current code, not merely a session — a stolen session cannot turn MFA off.

Resetting a password revokes every active session for that account.

await client.auth.requestPasswordReset(email); // always resolves
await client.auth.resetPassword(token, newPassword);

The token arrives by email and is hashed at rest. Groveback also serves a hosted reset form at /reset-password?token=…&project=… if you would rather not build one.

const sessions = await client.auth.sessions();
await client.auth.revokeSession(sessions[0].id);

Sessions store only a refreshTokenHash, never the raw token, and carry an expiresAt TTL index so expired rows disappear on their own.

The admin API manages end users at /api/v1/admin/users — create, update, and three statuses: active, disabled (recoverable, via deactivate) and blocked (a hard lock).

Any transition away from active immediately revokes all of that user’s refresh sessions. Outstanding access tokens still expire on their own, so plan for a window of up to 15 minutes.

Two details that catch people:

  • Assigning an end-user role is done through metadata.role. An unknown role name is rejected at write time — a typo must never silently fall back to the default.
  • update replaces metadata wholesale. There is no merge; send the full object.
import { createClient } from '@groveback/sdk';
const client = createClient({
baseUrl: 'https://api.example.com',
onTokensChanged: (tokens) =>
tokens ? localStorage.setItem('gb', JSON.stringify(tokens))
: localStorage.removeItem('gb'),
});
await client.auth.register('a@example.com', 'correct-horse', 'Ada');
await client.auth.login('a@example.com', 'correct-horse');

The SDK holds the token pair in a closure and, on a 401, transparently refreshes once and retries. A failed refresh clears the tokens and rethrows. Restore a saved session with auth.setTokens().

Next: social login for Google and GitHub.