---
title: "Authentication"
url: https://docs.growamal.com/en/authentication/
language: en
---

# 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](https://docs.growamal.com/en/guides/provision-users/), not register.

## Login

`POST /api/auth/login` with `{ email, password }` → `200` `AuthTokensResponse`
(`{ accessToken, refreshToken, expiresIn, tokenType }`). Bad credentials return `401`.

> **Warning:** **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](https://docs.growamal.com/en/getting-started/).

## Using the access token

Send the access token on every protected request:

```bash
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:

```bash
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 **2-digit `quickCode`** (01–99) + the class's **6-digit
`classCode`** returns a student-scoped token pair (`QuickCodeLoginResponse` with `tokens`,
`studentId`, `classId`). The two codes are one credential pair: the class code travels in the link
the teacher pushes (`/student-login?class=<classCode>`), the student types only their own code.

- The student code is **per class** (unique among the class's active students, so a class holds at
  most 99) and **never expires**; it is also the student's home-login password.
- The **class code is valid for one year** (365 days from when it was issued). A teacher renews it by hand if the link leaks, from the class-link panel (or
  `POST /api/classes/{classId}/login-code/rotate`); copying an expired link renews it automatically.
  The old link stops working at once. Student codes do not change when the class code does.
- An invalid or expired code (student or class) returns `401` with
  `code: "QUICKCODE_INVALID_OR_EXPIRED"`; too many attempts return `401` with
  `code: "QUICKCODE_RATE_LIMIT"`.
- `GET /api/auth/student/class-preview?classCode=` (public, throttled) returns the class and school
  names for the login page; an expired class code answers `410` with `code: "CLASS_CODE_EXPIRED"`.
- The direct `classId` form of the request is refused in production.

## Google SSO

LMS-imported users can sign in with their Google account. The flow uses PKCE and a short-lived
handoff code:

1. **Start:** `GET /api/auth/google/start?loginHint=<email>` returns `{ redirectUrl }`. Send the
   user's browser to `redirectUrl`.
2. **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.
3. **Exchange:** the web app immediately `POST`s `{ code }` to `POST /api/auth/google/exchange`
   and receives a standard `AuthTokensResponse`.

> **Warning:** `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.
