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".
What is (and is not) in the token
The JWT carries only userId, organizationId, and the role code, with no name, email, or grade.
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.