Concepts
Security
Your API account acts on behalf of real students, many of them children and teenagers. With it you can read their records, enter them for exams, and send a browser into their panel already signed in. This page explains how to protect the credentials that make that possible, how to limit what a leak could do, and what to do if something goes wrong.
Your two credentials
apiKey | apiSecret | |
|---|---|---|
| Looks like | key_ followed by 24 characters | secret_ followed by 43 characters |
| What it does | Names your account. It goes in the kid header and the sub claim of every token. | Signs your tokens. The API verifies every token against it. |
| Secret? | No, but there is no reason to publish it. | Yes. Anyone who holds it can act as your account. |
| Where you get it | Once, when your account is created | Once, when your account is created |
The apiSecret is shown once, when the account is created. It cannot be displayed again, recovered, or rotated. If you lose it, or it leaks, the way forward is a new account (see If something leaks).
Danger
Never put your apiSecret anywhere a person or program outside your server can read it: not in source control, a browser page, a mobile app, a desktop app, a spreadsheet or a support ticket. Never send it to anyone, including us. Support never needs it: a request_id is enough to find a request.
There is no exception: we never ask for it, on any page, and support never needs it.
Where to keep the secret
- Keep it in a secret manager, or in an environment variable injected at runtime. Keep it out of files in your repository, including
.envfiles that are committed or copied into images. - Give read access only to the process that signs tokens.
- Use the secret as it was given to you: the whole string, including
secret_, as UTF-8 bytes. Don't trim it, decode it or re-encode it. A secret that picked up a trailing newline or quotes from a config file signs tokens the API refuses.
Call the API from your server only
The production API sends no CORS headers, so a web page on another origin cannot call it. A page that could would have to carry your apiSecret, or tokens signed with it, to every visitor. Build your integration server to server. Browsers talk to your server, and your server talks to the API. The sandbox accepts browser requests from these documentation pages only.
The interactive "Try it" console on these pages works only against the sandbox, and takes a token, never your secret. Sign the token locally with your sandbox credentials and paste only the token. The console refuses anything that looks like an apiSecret.
Keep tokens short-lived
You sign your own tokens (see Authentication). A token is a bearer credential: whoever holds it can call the API as your account, within your roles, until it expires. The API accepts a lifetime of at most 3600 seconds, plus 30 seconds of clock tolerance. Shorter is better:
- Give tokens a lifetime of 15 minutes or less. Reuse one until about a minute before it expires, then sign a new one.
- Keep tokens in memory. Don't write them to disk, a shared cache or a database.
- A token is signed, not encrypted. Anyone who holds one can read its header and claims, including your
apiKey.
// token.mjs: sign and reuse a short-lived token (npm install jsonwebtoken)
import jwt from 'jsonwebtoken';
const API_KEY = process.env.MTO_API_KEY;
const API_SECRET = process.env.MTO_API_SECRET;
const LIFETIME_SECONDS = 15 * 60;
let cached = null;
export function getToken({ forceNew = false } = {}) {
const now = Math.floor(Date.now() / 1000);
if (!forceNew && cached && cached.exp - 60 > now) return cached.value;
const exp = now + LIFETIME_SECONDS;
const value = jwt.sign({ sub: API_KEY, iat: now, exp }, API_SECRET, {
algorithm: 'HS256',
keyid: API_KEY,
});
cached = { value, exp };
return value;
}
<?php
// token.php: sign and reuse a short-lived token (composer require firebase/php-jwt)
use Firebase\JWT\JWT;
function apiToken(bool $forceNew = false): string
{
static $cached = null;
$now = time();
if (!$forceNew && $cached !== null && $cached['exp'] - 60 > $now) {
return $cached['value'];
}
$apiKey = getenv('MTO_API_KEY');
$exp = $now + 15 * 60;
$value = JWT::encode(['sub' => $apiKey, 'iat' => $now, 'exp' => $exp], getenv('MTO_API_SECRET'), 'HS256', $apiKey);
$cached = ['value' => $value, 'exp' => $exp];
return $value;
}
Keep tokens and links out of logs
Redact the Authorization header in every log, trace and error report your HTTP client produces. Do the same for sign-in link URLs, which carry their token in the query string:
const SENSITIVE_HEADERS = new Set(['authorization', 'cookie']);
export function redactHeaders(headers) {
return Object.fromEntries(
Object.entries(headers).map(([name, value]) =>
SENSITIVE_HEADERS.has(name.toLowerCase()) ? [name, '[redacted]'] : [name, value],
),
);
}
export function redactUrl(href) {
const url = new URL(href);
if (url.searchParams.has('accessToken')) url.searchParams.set('accessToken', '[redacted]');
return url.toString();
}
Revoke a token you no longer trust
POST /v1/api-account/revoke-token revokes the token you send it with. The token stops working at once, for every request. Your other tokens and your apiSecret are unaffected.
curl -X POST https://api.main-team.org/v1/api-account/revoke-token \
-H "Authorization: Bearer $TOKEN_TO_REVOKE"
{
"success": true,
"message": "Token revoked successfully",
"data": { "expiresIn": 874 }
}
expiresIn is how many seconds the revocation is kept: the token's remaining lifetime plus the 30-second tolerance. After that the token would be refused for being expired anyway. The call needs the api/* permission. Revoke a token when it may have been exposed, for example when it appeared in a log, or when a server that held it is being retired.
If something leaks
| What leaked | What to do |
|---|---|
| A token | Revoke it with revoke-token, using that token. If you can't, it stops working 30 seconds after its exp, and no token's exp can be more than an hour after its iat. |
Your apiSecret | Email info@main-team.org straight away and ask for the account to be deactivated. Revoking tokens is not enough, because whoever holds the secret can sign new ones. |
| A sign-in link | A link works once and for 120 seconds, and there is no way to cancel one early. If you think somebody other than the student opened it, tell us. |
When the account is deactivated, every token signed with its secret stops working within 60 seconds and answers 401 unauthorized.
New credentials come as a new account. Students belong to the account that registered them, and a new account does not see the old one's students. Agree with us how to continue before you switch.
When you write to us, include:
- your
apiKey(never the secret or a token); - when you think the leak happened, in UTC;
- the
request_idof any request you don't recognize, from your own logs.
Ask for the least access you need
Every operation needs a permission, and an operator grants permissions to your account as roles. A role is { effect, action, target, authorized }: allow or disallow, an action such as application/create, an organization or *, and normally an empty authorized. The full grammar is in Permissions. Ask for the actions and organizations your integration actually uses, and no more:
| Your integration needs to | Roles to ask for (effect action target) |
|---|---|
| Check its credentials | allow api/* mto |
| Read reference data (countries, grades, organizations) | allow */read mto |
| Register and update students | allow student/* mto |
| Enter students for exams on stem | allow student/* stem · allow exam/read stem · allow exam-category/read stem · allow application/* stem |
| Collect results on stem | allow certificate/read stem · allow report/read stem |
| Send students into the panel on stem | allow auth/signin stem |
| Set students' passwords | allow auth/signin mto |
| Never delete an application anywhere | disallow application/delete * |
A disallow beats any allow it matches, which makes it a cheap safety net. disallow application/delete * means no bug in your code can ever delete an application.
auth/signin is the most powerful permission there is. With it, your account can sign a browser in as any of your students, or set a password they sign in with. It is never included in student/* or api/*. Ask for it only on the organizations where you send students into the panel, and on mto only if you set passwords.
To see which roles your account holds, call GET /v1/api-account/validate-me. It needs api/*, and it answers with the bare account, without the usual envelope:
curl https://api.main-team.org/v1/api-account/validate-me \
-H "Authorization: Bearer $TOKEN"
{
"_id": "66b2d0c4e1a9f3b27c8d4e10",
"apiKey": "key_Q2x5dW1uYXJ5LWV4YW1wbGUx",
"companyName": "Example Learning Ltd",
"scopes": [],
"roles": [
{ "effect": "allow", "action": "api/*", "target": "mto" },
{ "effect": "allow", "action": "student/*", "target": "mto" },
{ "effect": "allow", "action": "application/*", "target": "stem" },
{ "effect": "disallow", "action": "application/delete", "target": "*" }
],
"isActive": true
}
Note
Roles belong to the account, not to a server. Splitting one integration over several accounts doesn't help either: students belong to the account that registered them, and no account can see another's students. Keep one account per student population, and keep its secret on as few machines as possible.
Sign-in links are credentials
POST /v1/{organizationId}/auth/signin returns a URL that signs one of your students into one organization's panel:
{
"success": true,
"message": "Sign-in link generated successfully.",
"data": {
"url": "https://auth.main-team.org/api/user/oauth/invoke?accessToken=k3v9q0…&redirectUrl=%2Fdashboard",
"organization": "stem",
"studentId": "652f1c9b8e4b2a0012a3c4d5",
"expiresIn": 120
}
}
Whoever opens that URL first is signed in as the student. Treat it like a password that lasts two minutes:
- Check who is asking. The API trusts you to send the right person. Before you mint a link, make sure the user on your side is signed in to your system and is the student the link is for.
- Mint on click, redirect at once. Answer the student's click with a
302to the URL. Don't generate links ahead of time or in bulk. - Never send it anywhere else. No email, SMS, chat or push notification. Link previews and security scanners fetch URLs on their own and would use the link up, or sign themselves in.
- Never log it. The token is in the query string.
- Keep
redirectto a path. The optionalredirectmust be a path on the organization's own site, starting with a single/, such as/dashboard. Full URLs are refused, so a link can't send a student to another site.
A link works once, and only for 120 seconds (expiresIn). The API only mints links for students your account owns. A student of another account gets the same 404 not_found as one that doesn't exist. The student also needs access to that organization, or you get 403 forbidden with a message naming the organization. An unconfirmed email address does not block a link, but once the student lands, the panel asks them to confirm it before they can use the panel. See Sign-in links and Send a student to the panel.
Passwords
A password you set is a sign-in that doesn't expire. Prefer sign-in links: they last two minutes, work once, and leave nothing behind for anyone to steal.
If you do set passwords, through POST /v1/student or PUT /v1/student/{studentId}/password:
- Your account needs
auth/signinonmto. Without it, a registration carrying a password is refused with403 forbiddenbefore anything is written. - Generate a random password for each student. The API refuses passwords shorter than 5 characters, longer than 72 bytes, or containing the student's first name, surname, username, email address or the part of the address before the
@. Fragments shorter than 4 characters don't count. Never derive a password from the student's record: usernames are not secret. - Deliver the password to the student over a channel you trust, and don't keep the plain text afterwards. The API stores only a hash, and no operation ever returns a password, not even the one that set it.
- Once a student has confirmed their email address, the account is theirs. The password route then answers
409 conflict, and you should send them a sign-in link instead.
import { randomBytes } from 'node:crypto';
// 24 URL-safe characters from a secure random source: well inside 72 bytes.
const password = randomBytes(18).toString('base64url');
<?php
// 24 URL-safe characters from a secure random source: well inside 72 bytes.
$password = rtrim(strtr(base64_encode(random_bytes(18)), '+/', '-_'), '=');
If the API answers 400 because a generated password happens to contain part of the student's details, generate another one. See Passwords.
Students' personal data
This page gives no legal advice. Follow the data-protection rules that apply to you and to the students' schools. Some practical habits help whatever those rules are:
- Send only what the API needs. The API refuses any field it doesn't declare with
400, so you can't send it more than the documented fields anyway. Don't copy whole records out of your own system. - Keep only what you use. Every student record comes back with a fixed set of profile fields, never a password or anything about your account. Store the fields you need, for as long as you need them.
- Keep personal data out of URLs and request ids. No operation takes personal data in a query string. Don't put names or email addresses in
X-Request-Id, which is repeated in error bodies and logs. - Treat certificates and reports as personal documents. They carry a student's results. Store them encrypted, restrict who can open them, and delete them when you no longer need them.
- Limit access inside your organization. The API only ever shows your account its own students. A record your account didn't register answers exactly like one that doesn't exist. Hold your own staff to the same need-to-know standard.
Transport
- Always use
https://. API responses carryStrict-Transport-Security, so browsers and many HTTP clients will insist on it anyway. - Never turn off certificate verification (
curl -k,CURLOPT_SSL_VERIFYPEER => false,rejectUnauthorized: false). A client that accepts any certificate sends your tokens to whoever answers. - Keep your TLS library and your operating system's certificate store up to date.
What the API does on its side
These properties hold for every request, so you can rely on them:
- Every authentication failure looks the same: one
401with one message, whatever was wrong. A leaked key or token tells its finder nothing about whether it is live. - Only HS256 tokens are accepted, and only with a lifetime of at most an hour.
- Records outside your account look missing. Another account's student, application, certificate or report answers
404exactly like a record that doesn't exist. - Unknown request fields are refused, never silently ignored.
- Responses tell browsers not to sniff content, frame or run anything, through
X-Content-Type-Options,X-Frame-Optionsand a Content-Security-Policy that allows nothing to load. - The request log is minimal. For each request it records the request id, method, path without the query string, status, duration and your account id. Headers, tokens, query strings and bodies are not written to it.
Reporting a security problem
If you find a security issue in the API or in these pages, email info@main-team.org with a subject starting Security:. Include:
- what you found, and how to reproduce it;
- the
request_ids and UTC times of the requests involved; - your
apiKey, if your account was involved.
Never include your apiSecret, a token or a sign-in link, even an expired one. Please give us a chance to fix the issue before you tell anyone else about it. See Support.
Checklist
- The
apiSecretlives in a secret store or runtime environment, readable only by the signing process. - Nothing calls the API from a browser or an app. Every call comes from your server.
- Tokens live 15 minutes or less and stay in memory.
-
Authorizationheaders and sign-in URLs are redacted in every log. - You know how to revoke a token, and whom to email if the secret leaks.
- Your roles cover what you use and nothing more, with a
disallowon anything you never do. - Sign-in links are minted only for your own signed-in user, on click, and followed at once.
- Passwords, if you set any, are random per student and never kept in plain text.
- Certificates and reports are stored as personal documents.
- TLS certificate verification is on everywhere.