Guides
Student passwords
A student you registered can reach the panel in one of two ways: through a sign-in link 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).
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.
Note
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).
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.
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 shows it in full). It never contains the password.
- Omit
password, or sendnull, 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, codeforbidden, messageSetting 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.
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" }'
{
"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
- Your token and permission.
401if the token is refused.403withInsufficient role permissionsif the account lacksauth/signinonmto. - The body.
400ifpasswordis 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 also400. - The student.
404, codenot_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. - Email confirmation.
409once the student has confirmed their email address (next section). - 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
{
"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. You can still create
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
emailConfirmedbefore you call. It is on every student the API returns. When it istrue, 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, soemailConfirmedgoes back tofalse. 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
404withStudent not found!. Read the student again:emailConfirmedwill betrue.
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.
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
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');
}
Security
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 has more on handling student credentials.
Errors
| Status | Code | Message | Cause | Fix |
|---|---|---|---|---|
400 | bad_request | password must be a string | password is not a string. | Send a string. |
400 | bad_request | a message naming the 5-character minimum | Shorter than 5 characters, or empty. | Send a longer password. |
400 | 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 | 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 | property password should not exist | password sent to a student update route. | Use PUT /v1/student/<studentId>/password. |
400 | bad_request | property <name> should not exist | Another field in the password route's body. | Send password only. |
403 | forbidden | Insufficient role permissions | No auth/signin on mto for the password route. | Ask the operator for the permission. |
403 | 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 | 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 | 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: the single-use alternative, and what the student sees.
- Students: registration,
emailConfirmed, and changing a student's email. - Permissions: how
auth/signinand its target are matched.