# Student passwords

> Set a student's sign-in password at registration or later, with the permission it needs, the rules it must meet, and when it is refused.

A student you registered can reach the panel in one of two ways: through a
[sign-in link](https://hub.main-team.org/api/guides/sign-in-links) you create when they need one, or with a password. This page
covers passwords: where you can set one, the permission that takes, the rules a password must meet,
and why the API stops letting you set one once the student has confirmed their email address.

## Links or passwords?

Prefer sign-in links. A password is a standing way in as the student, which is exactly why the API is
careful about who may set one.

| | Sign-in link | Password you set |
|---|---|---|
| Lifetime | 120 seconds, single use | Until someone changes it |
| Who knows it | Only the browser you redirect | You, the student, and every system the password passed through |
| Permission | `auth/signin` on the organization in the path | `auth/signin` on `mto` |
| Works for a student who confirmed their email | Yes | No, the route answers `409` |
| Needs anything delivered to the student | No, you redirect them | Yes, you have to get the password to them |

Set passwords when the student really needs one: for example, a student who will sign in from a
device your integration does not control. Otherwise, create a link each time they click "Open
panel" in your product (see [Send a student to the panel](https://hub.main-team.org/api/tutorials/send-student-to-panel)).

## The permission

Setting a password takes the same permission a sign-in link takes, `auth/signin`, and it has to be
granted **on `mto`**: a role with action `auth/signin` (or `auth/*`, `*/signin`, `*/*`, `*`) and
target `mto` or `*`.

The reason: whoever knows a student's password can sign in as that student, with no expiry. That is at
least as strong as a sign-in link, so it cannot need less. Roles that manage students do **not** grant
it. An account holding `student/*` can register and update students, but gets `403` on the password
route.

`auth/signin` on one organization only, for example target `stem`, lets you create sign-in links for
stem but not set passwords. A password belongs to the core record, so it needs the permission on
`mto`.

## Where a password can be set

A student has one password, stored on the core record. The student signs in against that record, so
there is no per-organization password, and the same password works wherever they are entered.

| Route | `password` | What happens |
|---|---|---|
| `POST /v1/student` | optional | Sets the password when the student is created. Without `auth/signin` on `mto`: `403`. |
| `PUT /v1/student/<studentId>/password` | required, and the only field | Replaces the password. `409` once the student has confirmed their email. |
| `PUT /v1/student/<studentId>` | not accepted | `400`, `property password should not exist` |
| `PUT /v1/<organizationId>/student/<studentId>` | not accepted | `400`, `property password should not exist` |

The API stores a password only as a hash. No route ever returns it, not even the one that just set it,
and there is no route that checks a password or reads one back.

## The rules a password must meet

The rules are the platform's own, so any password a student could choose for themselves in the panel
is one you can set for them. The API adds one rule of its own, about the student's personal details.

| Rule | Detail |
|---|---|
| A string | A number, array, object or boolean is refused: `password must be a string`. |
| At least 5 characters | Counted in characters. `12345` is accepted. |
| At most 72 bytes | Counted in UTF-8 bytes, not characters: `password must be at most 72 bytes long, because bcrypt ignores anything past that`. |
| Not built from the student's own details | It must not contain the student's first name, last name, username, email address, or the part of the email before the `@`. The comparison ignores case, and a detail shorter than 4 characters is ignored. Message: `password must not contain the student's own name, username or email address`. |

Nothing else is checked. There is no requirement for upper case, digits or symbols, and spaces are
allowed. Common passwords are not refused either. That keeps the API no stricter than the platform,
so you can pass on a password a student chose themselves. When you choose one, generate it at random
(see [Generate a password](#generate-a-password)).

### Why bytes, not characters

The hash reads at most 72 bytes of a password and silently ignores the rest. A longer password would
promise strength the stored hash does not have, so it is refused instead. Letters outside ASCII take
more than one byte:

| Password | Characters | Bytes | Accepted |
|---|---|---|---|
| `grapefruit lantern quarry` | 25 | 25 | yes |
| `q` repeated 73 times | 73 | 73 | no |
| `ş` repeated 40 times | 40 | 80 | no |
| `ş` repeated 36 times | 36 | 72 | yes |

### The personal-details rule, by example

Take a student registered as `firstName: "Katherine"`, `lastName: "Lovelace"`,
`email: "k.pioneer@example.org"`, whose minted username is `XXB1045`.

| Password | Result | Why |
|---|---|---|
| `Katherine-2026` | refused | contains the first name |
| `river LOVELACE 9` | refused | contains the last name, ignoring case |
| `k.pioneer!42` | refused | contains the part of the email before `@` |
| `xxb1045-summer` | refused on the password route | contains the username |
| `Kat-river-2026` | accepted | `Kat` is not the whole first name |
| `grapefruit lantern quarry` | accepted | shares nothing with the student |

At registration the username does not exist yet, because registration is what creates it. So
the check against the username only happens on `PUT /v1/student/<studentId>/password`, which reads the
stored record first. This rule exists because an integration that sets every student's password to
their username sets thousands of passwords anyone can guess. Usernames are what students and support
use to refer to an account.

## Set a password at registration

Add `password` to the registration body. The account needs `student/create` and `auth/signin` on
`mto`.

```bash
curl -X POST "https://api.main-team.org/v1/student" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "firstName": "Katherine",
    "lastName": "Lovelace",
    "email": "k.pioneer@example.org",
    "birth": "14/05/2010",
    "sex": "f",
    "country": "630e0182c53dc79a6836e67e",
    "grade": "9",
    "city": "Berlin",
    "school": "Berlin International School",
    "password": "grapefruit lantern quarry"
  }'
```

The response is the usual registration response, `201` with the new student
([Students](https://hub.main-team.org/api/guides/students#register-a-student) shows it in full). It never contains the password.

- **Omit `password`**, or send `null`, and the student is created without one. That needs no extra
  permission. The student can still reach the panel through a sign-in link.
- **Send one without the permission** and the whole registration is refused, before anything is
  written: `403`, code [`forbidden`](https://hub.main-team.org/api/errors#forbidden), message
  `Setting a student's password needs the auth/signin permission on mto, the same grant a sign-in link needs.`
  Resend without the field, or ask for the permission.
- **An empty string is not "no password".** It fails the 5-character rule with `400`.

Hashing is deliberately slow, so a request that sets a password takes noticeably longer than one that
does not, often more than a second. Allow for it in your timeouts, especially when you register in
bulk.

## Change a password later

`PUT /v1/student/<studentId>/password` replaces the password of one of your students. The body has
exactly one field.

```bash
curl -X PUT "https://api.main-team.org/v1/student/6650f1a2b3c4d5e6f7a8b9c0/password" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "password": "grapefruit lantern quarry" }'
```

```json
{
  "success": true,
  "message": "Password set.",
  "data": {
    "_id": "6650f1a2b3c4d5e6f7a8b9c0",
    "username": "XXB1045",
    "firstName": "Katherine",
    "lastName": "Lovelace",
    "fullName": "Katherine Lovelace",
    "email": "k.pioneer@example.org",
    "emailConfirmed": false,
    "birth": "14/05/2010",
    "sex": "f",
    "country": "630e0182c53dc79a6836e67e",
    "city": "6650e0a1b2c3d4e5f6a7b801",
    "school": "6650e0a1b2c3d4e5f6a7b8c2",
    "grade": "630e01826836e67ec53dc7a5",
    "activatedPlatformsThisSeason": ["common"],
    "createdAt": "2026-09-01T09:12:44.512Z",
    "updatedAt": "2026-09-15T10:03:18.090Z"
  }
}
```

`<studentId>` is the `_id` you got when you registered the student.

### What is checked, in order

1. **Your token and permission.** `401` if the token is refused. `403` with
   `Insufficient role permissions` if the account lacks `auth/signin` on `mto`.
2. **The body.** `400` if `password` is missing, not a string, shorter than 5 characters or longer than
   72 bytes, or if the body carries any other field (`property <name> should not exist`). A
   `<studentId>` that is not 24 hex characters is also `400`.
3. **The student.** `404`, code [`not_found`](https://hub.main-team.org/api/errors#not_found), `Student not found!` when no
   student with that id belongs to your account. A student registered by another account gets exactly
   the same answer.
4. **Email confirmation.** `409` once the student has confirmed their email address (next section).
5. **The personal-details rule**, checked against the student as stored, username included:
   `400`, `password must not contain the student's own name, username or email address`.

Nothing is written unless every step passes.

## Once the student confirms their email, the password is theirs

```json
{
  "error": {
    "code": "conflict",
    "message": "This student has confirmed their email address, so the password is theirs to change. Send them a sign-in link with POST /v1/:organizationId/auth/signin.",
    "documentation_url": "https://hub.main-team.org/api/errors#conflict",
    "request_id": "8b1f0c2d-3e4a-4b5c-9d6e-7f8a9b0c1d2e"
  }
}
```

A confirmed address means the student has proved that the email on the account reaches them. From
then on the account is theirs. A password set over theirs would lock them out of their own account,
so the route refuses with `409`, code [`conflict`](https://hub.main-team.org/api/errors#conflict). You can still create
[sign-in links](https://hub.main-team.org/api/guides/sign-in-links) for them: a link does not replace their password.

What you need to know about confirmation:

- **Every student you register starts unconfirmed.** The API never marks an address as confirmed.
- **The student confirms it themselves**, by proving they can read that mailbox: for example, by
  entering a code the platform emails to them, or by signing in with a Google account at that address.
- **Read `emailConfirmed` before you call.** It is on every student the API returns. When it is `true`,
  skip the password route and use a sign-in link.
- **Confirmation belongs to an address.** If you change the student's `email`, the new address has
  not been proved, so `emailConfirmed` goes back to `false`. Sending the address the student already
  has, in any letter case, changes nothing.
- **A student who confirms at the same moment you set a password wins.** If confirmation lands
  between the check and the write, nothing is written and you get `404` with `Student not found!`.
  Read the student again: `emailConfirmed` will be `true`.

## Generate a password

When you set a password, generate it: one per student, at random, never derived from the student's
details. The examples below give 16 characters from a URL-safe alphabet: well inside the limits, with
about 96 bits of randomness.

```js [Node.js]
import { randomBytes } from 'node:crypto';

const BASE = 'https://api.main-team.org/v1';

export function generatePassword() {
  return randomBytes(12).toString('base64url'); // 16 ASCII characters = 16 bytes
}

export async function setPassword(token, studentId) {
  for (let attempt = 0; attempt < 3; attempt++) {
    const password = generatePassword();
    const res = await fetch(`${BASE}/student/${studentId}/password`, {
      method: 'PUT',
      headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
      body: JSON.stringify({ password }),
    });
    if (res.ok) return password; // deliver it to the student; never log it

    const { error } = await res.json();
    if (res.status === 409) return null; // confirmed student: use a sign-in link instead
    // A random password colliding with the student's details is very unlikely, but retry once more.
    if (res.status === 400 && error.message.includes("student's own")) continue;
    throw new Error(`${res.status} ${error.code}: ${error.message} (request ${error.request_id})`);
  }
  throw new Error('Could not generate an acceptable password');
}
```

```php [PHP]
<?php
const MT_BASE = 'https://api.main-team.org/v1';

function mt_generate_password(): string
{
    // 12 random bytes -> 16 URL-safe characters
    return rtrim(strtr(base64_encode(random_bytes(12)), '+/', '-_'), '=');
}

function mt_set_password(string $token, string $studentId): ?string
{
    for ($attempt = 0; $attempt < 3; $attempt++) {
        $password = mt_generate_password();
        $ch = curl_init(MT_BASE . '/student/' . rawurlencode($studentId) . '/password');
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => 'PUT',
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => [
                'Authorization: Bearer ' . $token,
                'Content-Type: application/json',
            ],
            CURLOPT_POSTFIELDS => json_encode(['password' => $password]),
            CURLOPT_TIMEOUT => 30,
        ]);
        $raw = curl_exec($ch);
        $status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);

        if ($status === 200) {
            return $password; // deliver it to the student; never log it
        }
        $error = json_decode($raw, true)['error'] ?? [];
        if ($status === 409) {
            return null; // confirmed student: use a sign-in link instead
        }
        if ($status === 400 && str_contains($error['message'] ?? '', "student's own")) {
            continue;
        }
        throw new RuntimeException("$status {$error['code']}: {$error['message']} (request {$error['request_id']})");
    }
    throw new RuntimeException('Could not generate an acceptable password');
}
```

**Treat a password like a secret from the moment you generate it.** Keep it out of logs, analytics and
error reports. Get it to the student over a channel you already trust, and do not keep it after it
has been delivered. The API cannot show it again, and neither should your system. Redact request
bodies of this route in any HTTP logging you run. [Security](https://hub.main-team.org/api/security) has more on handling
student credentials.

## Errors

| Status | Code | Message | Cause | Fix |
|---|---|---|---|---|
| `400` | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `password must be a string` | `password` is not a string. | Send a string. |
| `400` | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | a message naming the 5-character minimum | Shorter than 5 characters, or empty. | Send a longer password. |
| `400` | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `password must be at most 72 bytes long, because bcrypt ignores anything past that` | Over 72 UTF-8 bytes. | Shorten it; count bytes, not characters. |
| `400` | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `password must not contain the student's own name, username or email address` | Built from the student's details. | Generate a random password. |
| `400` | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `property password should not exist` | `password` sent to a student update route. | Use `PUT /v1/student/<studentId>/password`. |
| `400` | [`bad_request`](https://hub.main-team.org/api/errors#bad_request) | `property <name> should not exist` | Another field in the password route's body. | Send `password` only. |
| `403` | [`forbidden`](https://hub.main-team.org/api/errors#forbidden) | `Insufficient role permissions` | No `auth/signin` on `mto` for the password route. | Ask the operator for the permission. |
| `403` | [`forbidden`](https://hub.main-team.org/api/errors#forbidden) | `Setting a student's password needs the auth/signin permission on mto, the same grant a sign-in link needs.` | A registration with `password` from an account without the permission. | Register without `password`, or get the permission. |
| `404` | [`not_found`](https://hub.main-team.org/api/errors#not_found) | `Student not found!` | No such student on your account, or the student confirmed their email at that very moment. | Check the id; read the student again. |
| `409` | [`conflict`](https://hub.main-team.org/api/errors#conflict) | `This student has confirmed their email address, …` | The account belongs to the student now. | Send a sign-in link instead. |

When a request breaks several rules at once, the message names one of them. Fix it and send again.

## Related

- [Sign-in links](https://hub.main-team.org/api/guides/sign-in-links): the single-use alternative, and what the student sees.
- [Students](https://hub.main-team.org/api/guides/students): registration, `emailConfirmed`, and changing a student's email.
- [Permissions](https://hub.main-team.org/api/permissions): how `auth/signin` and its target are matched.
