Skip to content
API documentation
View as MarkdownOpen in Claude

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.

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 linkPassword you set
Lifetime120 seconds, single useUntil someone changes it
Who knows itOnly the browser you redirectYou, the student, and every system the password passed through
Permissionauth/signin on the organization in the pathauth/signin on mto
Works for a student who confirmed their emailYesNo, the route answers 409
Needs anything delivered to the studentNo, you redirect themYes, 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.

RoutepasswordWhat happens
POST /v1/studentoptionalSets the password when the student is created. Without auth/signin on mto: 403.
PUT /v1/student/<studentId>/passwordrequired, and the only fieldReplaces the password. 409 once the student has confirmed their email.
PUT /v1/student/<studentId>not accepted400, property password should not exist
PUT /v1/<organizationId>/student/<studentId>not accepted400, 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.

RuleDetail
A stringA number, array, object or boolean is refused: password must be a string.
At least 5 charactersCounted in characters. 12345 is accepted.
At most 72 bytesCounted 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 detailsIt 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:

PasswordCharactersBytesAccepted
grapefruit lantern quarry2525yes
q repeated 73 times7373no
ş repeated 40 times4080no
ş repeated 36 times3672yes

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.

PasswordResultWhy
Katherine-2026refusedcontains the first name
river LOVELACE 9refusedcontains the last name, ignoring case
k.pioneer!42refusedcontains the part of the email before @
xxb1045-summerrefused on the password routecontains the username
Kat-river-2026acceptedKat is not the whole first name
grapefruit lantern quarryacceptedshares 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 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, 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.

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

  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, 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

{
  "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 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.

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

StatusCodeMessageCauseFix
400bad_requestpassword must be a stringpassword is not a string.Send a string.
400bad_requesta message naming the 5-character minimumShorter than 5 characters, or empty.Send a longer password.
400bad_requestpassword must be at most 72 bytes long, because bcrypt ignores anything past thatOver 72 UTF-8 bytes.Shorten it; count bytes, not characters.
400bad_requestpassword must not contain the student's own name, username or email addressBuilt from the student's details.Generate a random password.
400bad_requestproperty password should not existpassword sent to a student update route.Use PUT /v1/student/<studentId>/password.
400bad_requestproperty <name> should not existAnother field in the password route's body.Send password only.
403forbiddenInsufficient role permissionsNo auth/signin on mto for the password route.Ask the operator for the permission.
403forbiddenSetting 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.
404not_foundStudent 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.
409conflictThis 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.

  • Sign-in links: the single-use alternative, and what the student sees.
  • Students: registration, emailConfirmed, and changing a student's email.
  • Permissions: how auth/signin and its target are matched.

Search the API documentation

Guides, endpoints by name, path or permission, and error codes such as not_found.