Skip to Content

Authentication

Amal uses Bearer JWT authentication with no session cookies. A short-lived access token is paired with a long-lived, rotating refresh token.

TokenLifetimeStored whereRotates?
Access token15 minutes (expiresIn: 900)client only; statelessno
Refresh token7 daysserver-sideyes, 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:

  1. POST /api/auth/password-reset with { email }always 200 (it never reveals whether the email exists) → a magic link is sent (1-hour TTL).
  2. POST /api/auth/password-reset/confirm with { 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 401 with code: "QUICKCODE_INVALID_OR_EXPIRED"; too many attempts return 401 with code: "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.

Last updated on