Authentication
Amal uses Bearer JWT authentication with no session cookies. A short-lived access token is paired with a long-lived, rotating refresh token.
| Token | Lifetime | Stored where | Rotates? |
|---|---|---|---|
| Access token | 15 minutes (expiresIn: 900) | client only; stateless | no |
| Refresh token | 7 days | server-side | yes, on every use |
Register
POST /api/auth/register is bootstrap only. The first call creates a SUPER-scope admin and
returns a token pair. After that, registration is closed: any further call returns 403 with
code: "PERMISSION_DENIED".
organizationId is required for every persona except the first SUPER admin, which omits it.
All later users are created through Provision Users, not register.
Login
POST /api/auth/login with { email, password } → 200 AuthTokensResponse
({ accessToken, refreshToken, expiresIn, tokenType }). Bad credentials return 401.
Brute-force protection returns 401 + Retry-After, not 429. After 5 failed attempts per
email within 15 minutes the endpoint locks and responds with 401 and a Retry-After header (the
seconds to wait). X-RateLimit-* headers appear after the first failed attempt. Do not expect a
bare 429 from this API.
A worked login request + response (curl and TypeScript) is in Getting Started → Step 2.
Using the access token
Send the access token on every protected request:
curl https://localhost:3000/api/auth/me \
-H "Authorization: Bearer <accessToken>"GET /api/auth/me returns your identity plus the permissions[] array that gates every endpoint
(MeResponse).
Refresh and rotation
POST /api/auth/refresh with { refreshToken } → a new AuthTokensResponse. Refresh tokens rotate:
the old token is invalidated on use. **Reusing a rotated refresh token returns 401 and revokes the
session (theft detection). Always store the newest refresh token from the latest response.
Logout
POST /api/auth/logout (requires a valid access token) invalidates the session server-side. Access
tokens are not individually revocable; they are short-lived by design, so they simply expire.
Password reset
A two-step, enumeration-safe flow:
POST /api/auth/password-resetwith{ email }→ always200(it never reveals whether the email exists) → a magic link is sent (1-hour TTL).POST /api/auth/password-reset/confirmwith{ token, newPassword }→ resets the password and revokes all existing sessions.
Student quickCode login
For Grade-1 usability, students log in without email or password:
curl -X POST https://localhost:3000/api/auth/student/quickcode \
-H "Content-Type: application/json" \
-d '{ "quickCode": "472", "classId": "clx4z2k0u0000xyz1234abcde" }'POST /api/auth/student/quickcode with a 3-digit quickCode + the class classId (cuid)
returns a student-scoped token pair (QuickCodeLoginResponse with tokens, studentId, classId).
- Codes are per class and regenerate on a 90-day window (see Manage Classes & Rosters).
- An invalid or expired code returns
401withcode: "QUICKCODE_INVALID_OR_EXPIRED"; too many attempts return401withcode: "QUICKCODE_RATE_LIMIT".
Google SSO
LMS-imported users can sign in with their Google account. The flow uses PKCE and a short-lived handoff code:
- Start:
GET /api/auth/google/start?loginHint=<email>returns{ redirectUrl }. Send the user’s browser toredirectUrl. - Callback: Google redirects to
GET /api/auth/google/callback. The server issues a 60-second single-use?code=query parameter and redirects the browser to the web app. - Exchange: the web app immediately
POSTs{ code }toPOST /api/auth/google/exchangeand receives a standardAuthTokensResponse.
GET /api/auth/google/start returns 503 when GOOGLE_CLIENT_ID is not configured in the
environment. A domain allow-list (allowedGoogleDomains on the organisation record) controls
which Google accounts are accepted.
What is (and is not) in the token
The JWT carries userId (sub), organizationId (orgId), the role code (role), and the
session ID (sid), along with standard iat/exp claims. No name, email, or grade is embedded.
Read those from GET /api/auth/me instead. There is no token secret-rotation / sv claim in
Wave 1 (deferred to a later wave), so don’t build against one.