Skip to content
API documentation
View as MarkdownOpen in Claude

Guides

Sign-in links

A sign-in link signs one of your students in to an organization's student panel. You don't need their password. You request the link from your server and then send the student's browser to it. It works once, and only for 120 seconds.

Links are the recommended way to get students into the panel. You don't have to create, store or hand out passwords, and you can decide when each student enters. The link also does a second job: the first time a student follows a link to an organization, that organization creates its own copy of the student. Applications and supervisor links need that copy (see Why the first sign-in matters).

Before you start

A link is issued only when all of the following are true:

RequirementHow to meet it
Your account holds the auth/signin permission on the organization in the pathAsk your operator for an allow role with action auth/signin (or auth/*) and target * or that organization's slug. student/* roles do not grant it. See Permissions.
The student is yoursThe studentId must be a student your API account registered. Another account's student answers exactly like a missing one. See Organizations.
The student has access to that organizationStudents you register get access to every organization unless you restrict it. See Granting access to an organization.

The student does not need a confirmed email address to use a link. A student you register through POST /v1/student starts out unconfirmed, and you can send them a link immediately. Once they land, the panel asks them to confirm the address before they can use it. See Email confirmation.

Security

Signing in as a student is a stronger power than reading or updating one, which is why it has its own permission. The same auth/signin grant (on mto) is needed to set a student's password. Give it only to the server that actually sends students into the panel.

POST /v1/<organizationId>/auth/signin

<organizationId> is the organization's _id from GET /v1/organization. It is never the slug. The link signs the student in to that organization.

Request body

FieldTypeRequiredRules
studentIdstringyesThe student's core id: the _id that registration returned. A 24-character hexadecimal id.
redirectstringnoWhere the student lands after signing in. It must be a path on the organization's own site; see The redirect rule. Leave it out to land on the organization's home page.

Any other field is refused with 400 bad_request (property <name> should not exist).

curl -s -X POST "https://api.main-team.org/v1/<organizationId>/auth/signin" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d '{ "studentId": "652f1c9b8e4b2a0012a3c4d5", "redirect": "/dashboard" }'
{
  "success": true,
  "message": "Sign-in link generated successfully.",
  "data": {
    "url": "https://auth.main-team.org/api/user/oauth/invoke?accessToken=q7x2m9k4d1c8…&redirectUrl=%2Fdashboard",
    "organization": "stem",
    "studentId": "652f1c9b8e4b2a0012a3c4d5",
    "expiresIn": 120
  }
}

The token in url is 256 characters long; it is shortened here.

Response fields

FieldMeaning
urlThe link. Send the student's browser to it. Treat it as opaque: don't parse it, store it or build one yourself, because its host and shape may change.
organizationThe slug of the organization the link signs in to, for example stem.
studentIdThe core id of the student, as you sent it.
expiresInSeconds the link stays valid from the moment it was issued. Always 120.

Your API account gets nothing else. No token, cookie or session is issued to you, only the URL.

The redirect rule

redirect must be a site-relative path. The API refuses anything that could send the student to another site, so a link can never be turned into an open redirect.

A valid redirect:

  • starts with exactly one /;
  • does not continue with a second / or a \ (//host and /\host both point at another site in a browser);
  • contains no whitespace and no \ anywhere.
redirectResult
/dashboardaccepted
/accepted
/exams?tab=upcomingaccepted
/my%20examsaccepted (percent-encode spaces)
/profile/{userId}accepted; see below
dashboardrefused: no leading /
https://example.com/refused: absolute URL
//example.comrefused: protocol-relative
/\example.comrefused: backslash
/my examsrefused: whitespace

A refused redirect answers 400 bad_request with the message redirect must be a site-relative path starting with "/" (e.g. "/dashboard").

The literal text {userId} in the path is replaced with the student's id inside that organization when they land. That id is not the core id you know them by (see Identifiers), so use {userId} whenever a panel path needs the student's own id. Only the first {userId} in the path is replaced, so use it once.

What is checked, in order

Every request goes through the same steps, and the first one that fails decides the answer. Branch on the status and error.code, never on the message text.

#CheckRefusal
1The body is at most 100 kB of UTF-8 JSON413 payload_too_large or 415 unsupported_media_type, before anything else is read
2Your token is valid401 unauthorized
3<organizationId> names an organization404 not_found, Organization not found!
4Your account holds auth/signin on that organization403 forbidden, Insufficient role permissions
5You are within the rate limit for this operation429 too_many_requests; wait Retry-After seconds
6The body is valid: studentId is a 24-character hex id, redirect follows the rule, and there are no other fields400 bad_request, for example studentId must be a mongodb id
7The student exists, is a student, and belongs to your account404 not_found, Student not found!
8The student has access to this organization403 forbidden, Student is not activated for organization stem. (the slug varies)
9The link is issued500 internal_error, Could not issue a sign-in token, please retry. This is rare and safe to retry.

A request refused at step 8 changes nothing. The permission refusal at step 4 and the access refusal at step 8 are both 403, but they have different fixes: the first needs your operator, the second needs you (see the next section). Tell them apart by the message.

Granting access to an organization

Each student has a list of organizations they can use this season. At registration it defaults to ["common"], which means every organization. If you registered a student with a narrower list, for example ["stem"], a link to neo answers 403 forbidden until you add neo.

A successful update through PUT /v1/<organizationId>/student/<studentId> that leaves activatedPlatformsThisSeason out of the body adds that organization to the student's list. It adds nothing when the list already holds common. An update that does send activatedPlatformsThisSeason adds the values in it instead, and adds the organization in the path only if you name it. No update ever removes an entry. The update works even for a student who doesn't have access to that organization yet, which is how you grant it. The body can be empty: {} grants access and changes nothing else. The Students guide covers the update in full.

The link works once, for 120 seconds. Request it when the student asks to go to the panel, and redirect their browser to it in the same response. Never generate links in advance.

Node.js (Express)

import express from 'express';

const app = express();
const API = 'https://api.main-team.org/v1';

// Behind your own login. POST, so link previewers and prefetchers never trigger it.
app.post('/go-to-panel', requireLogin, async (req, res) => {
  // Look the student up from your own session, never from the request body.
  const { studentId, organizationId } = await studentForUser(req.user);

  const apiRes = await fetch(`${API}/${organizationId}/auth/signin`, {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${await getToken()}`, // see /api/tutorials/token-handling
      'Content-Type': 'application/json',
    },
    body: JSON.stringify({ studentId, redirect: '/dashboard' }),
  });
  const body = await apiRes.json();

  if (!apiRes.ok) {
    // Log the code and request_id, never the body of a successful response.
    console.warn('sign-in link refused', apiRes.status, body.error?.code, body.error?.request_id);
    return res.status(502).send('We could not open the panel. Please try again.');
  }

  res.set('Cache-Control', 'no-store');
  res.set('Referrer-Policy', 'no-referrer');
  res.redirect(302, body.data.url);
});

PHP

<?php
// go-to-panel.php: reached by a POST form behind your own login.
[$studentId, $organizationId] = studentForCurrentUser(); // from your session

$ch = curl_init("https://api.main-team.org/v1/{$organizationId}/auth/signin");
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => [
        'Authorization: Bearer ' . getToken(), // see /api/tutorials/token-handling
        'Content-Type: application/json',
    ],
    CURLOPT_POSTFIELDS => json_encode(['studentId' => $studentId, 'redirect' => '/dashboard']),
    CURLOPT_TIMEOUT => 30,
]);
$body = json_decode(curl_exec($ch), true);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);

if ($status !== 200) {
    error_log("sign-in link refused: {$status} {$body['error']['code']} {$body['error']['request_id']}");
    http_response_code(502);
    exit('We could not open the panel. Please try again.');
}

header('Cache-Control: no-store');
header('Referrer-Policy: no-referrer');
header('Location: ' . $body['data']['url'], true, 302);
exit;

The Send a student to the panel tutorial builds these into complete routes.

What the student experiences

  1. Their browser opens the link. It passes through the platform's sign-in host, auth.main-team.org.
  2. The platform signs them in to the organization and sends them on to that organization's panel.
  3. They land on the redirect path, or on the organization's home page if you left it out.
  4. If their email address isn't confirmed yet, the panel asks them to confirm it, and keeps asking until they do. See Email confirmation.

If the link was already used, or more than 120 seconds have passed, the browser shows an error instead: Invalid or expired access token. The fix is always a new link; there is no way to revive an old one.

Rarely, the organization can't complete the sign-in, and the browser shows an error that starts with Error signing in to organization. That link is spent too. Request a new one and try again, and contact support with the time if it keeps happening.

The first request to the URL spends it, whoever makes it. Plenty of software requests URLs without a person clicking:

  • chat and email apps that build link previews;
  • email security scanners that open every link in a message;
  • browser prefetching and "preload" hints;
  • monitoring or logging tools that replay URLs.

Any of these can spend a link before the student gets to it, and the student then sees Invalid or expired access token. So:

  • Never send a link by email, SMS or chat. Send the browser to it directly, as in the examples above.
  • Never show it on a page for the student to click later.
  • Never write it to logs, analytics or error reports. Anyone who sees an unused link can sign in as the student.
  • Retrying the API call is fine. A new call returns a new link. Retrying the URL itself never helps.

Security

Treat a link like a password that lasts two minutes. Anyone who holds it can sign in as the student. Send Referrer-Policy: no-referrer and Cache-Control: no-store on the response that redirects to it, and keep it out of your logs. The Security page lists the rest.

Why the first sign-in matters

Every organization keeps its own copy of each student, linked to the core record by mainId. You can't create that copy through the API. The organization creates it the first time the student signs in to it, and following a sign-in link counts.

Until a student has signed in to an organization once:

Operation on that organizationAnswer
Create an application409 conflict: Student has never signed in to stem, so stem holds no record for them. Generate a sign-in link first with POST /:organizationId/auth/signin.
List the student's applications, certificates or reportsthe same 409 conflict
Link a supervisor404 not_found, Not found! (this route gives one answer to every refusal)
List the exams the student can apply toworks; nothing needs to exist for this read

So the usual order for a new student is: register, send a sign-in link, and only then create applications. The Register a student and apply tutorial walks through it.

Email confirmation

The link doesn't depend on the student's email address at all. It comes back to you in the API response and is never mailed anywhere, so an address nobody has proved receives nothing.

  • A student registered through the API starts out with emailConfirmed: false and can use links straight away.
  • If you change a student's email address, confirmation is withdrawn until the student proves the new address. Links still work.

When a student whose address isn't confirmed lands in the panel, the panel asks them to confirm it:

  1. The panel shows the address, partly masked, and offers to send a code to it.
  2. The student receives an email with a 6-digit code. It comes from no-reply@main-team.org, so tell students to check their spam folder if it doesn't arrive.
  3. They type the code into the panel. A code is valid for 15 minutes, and they can ask for a new one after 60 seconds.
  4. Once the code is accepted, the address is confirmed on the student's core record, and emailConfirmed on the student reads true in the API from then on.

The prompt stays until the address is confirmed. It appears on every page of the panel, and the student can't dismiss it or put it off; the only way out is to sign out. It covers My Exams too, the page where a student starts an exam, so a student must confirm before they can start one. A student already inside an exam room is not interrupted. Have your students confirm well before an exam day, for example right after their first sign-in, so an exam never waits on an email. The sandbox sends no emails, so its panels don't ask.

Only the confirmation on the student's core record counts. The panel goes by the emailConfirmed that GET /v1/student/{studentId} returns. Make sure the address you register is one the student reads: a student can't receive a code at an address that isn't theirs. Tell your students, before their first sign-in, that they will need to reach their mailbox.

Your integration doesn't take part in this. You can't send the code or confirm an address through the API. Read emailConfirmed on the student record to see where a student stands.

Confirmation matters for passwords: once a student has confirmed their address, you can no longer set their password, and a link is the way in. See Passwords.

Limits

LimitValue
Link lifetime120 seconds from issue
Uses per link1
Request body100 kB
Requests to this operation100 per 60 seconds per API account; see Rate limits

Search the API documentation

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