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:
| Requirement | How to meet it |
|---|---|
Your account holds the auth/signin permission on the organization in the path | Ask 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 yours | The 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 organization | Students 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.
Request a link
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
| Field | Type | Required | Rules |
|---|---|---|---|
studentId | string | yes | The student's core id: the _id that registration returned. A 24-character hexadecimal id. |
redirect | string | no | Where 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
| Field | Meaning |
|---|---|
url | The 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. |
organization | The slug of the organization the link signs in to, for example stem. |
studentId | The core id of the student, as you sent it. |
expiresIn | Seconds 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\(//hostand/\hostboth point at another site in a browser); - contains no whitespace and no
\anywhere.
redirect | Result |
|---|---|
/dashboard | accepted |
/ | accepted |
/exams?tab=upcoming | accepted |
/my%20exams | accepted (percent-encode spaces) |
/profile/{userId} | accepted; see below |
dashboard | refused: no leading / |
https://example.com/ | refused: absolute URL |
//example.com | refused: protocol-relative |
/\example.com | refused: backslash |
/my exams | refused: 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.
| # | Check | Refusal |
|---|---|---|
| 1 | The body is at most 100 kB of UTF-8 JSON | 413 payload_too_large or 415 unsupported_media_type, before anything else is read |
| 2 | Your token is valid | 401 unauthorized |
| 3 | <organizationId> names an organization | 404 not_found, Organization not found! |
| 4 | Your account holds auth/signin on that organization | 403 forbidden, Insufficient role permissions |
| 5 | You are within the rate limit for this operation | 429 too_many_requests; wait Retry-After seconds |
| 6 | The body is valid: studentId is a 24-character hex id, redirect follows the rule, and there are no other fields | 400 bad_request, for example studentId must be a mongodb id |
| 7 | The student exists, is a student, and belongs to your account | 404 not_found, Student not found! |
| 8 | The student has access to this organization | 403 forbidden, Student is not activated for organization stem. (the slug varies) |
| 9 | The link is issued | 500 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.
Send the student to the link
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
- Their browser opens the link. It passes through the platform's sign-in host,
auth.main-team.org. - The platform signs them in to the organization and sends them on to that organization's panel.
- They land on the
redirectpath, or on the organization's home page if you left it out. - 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.
Single-use: what uses up a link
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 organization | Answer |
|---|---|
| Create an application | 409 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 reports | the same 409 conflict |
| Link a supervisor | 404 not_found, Not found! (this route gives one answer to every refusal) |
| List the exams the student can apply to | works; 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: falseand 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:
- The panel shows the address, partly masked, and offers to send a code to it.
- 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. - 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.
- Once the code is accepted, the address is confirmed on the student's core record, and
emailConfirmedon the student readstruein 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
| Limit | Value |
|---|---|
| Link lifetime | 120 seconds from issue |
| Uses per link | 1 |
| Request body | 100 kB |
| Requests to this operation | 100 per 60 seconds per API account; see Rate limits |
Related
POST /v1/{organizationId}/auth/signin: the full reference- Send a student to the panel
- Passwords, the alternative to links
- Troubleshooting: "Invalid or expired access token"
- Error codes:
forbidden,not_found,bad_request