# Security

> Protect your apiSecret and tokens, ask for the least access you need, treat sign-in links and passwords as credentials, and report problems.

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](#if-something-leaks)).

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 `.env` files 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](https://hub.main-team.org/api/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`.

```js
// 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
<?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:

```js
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`](https://hub.main-team.org/api/reference/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.

```bash
curl -X POST https://api.main-team.org/v1/api-account/revoke-token \
  -H "Authorization: Bearer $TOKEN_TO_REVOKE"
```

```json
{
  "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_id` of 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](https://hub.main-team.org/api/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`](https://hub.main-team.org/api/reference/get-current-api-account). It needs `api/*`, and it answers with the bare account, without the usual envelope:

```bash
curl https://api.main-team.org/v1/api-account/validate-me \
  -H "Authorization: Bearer $TOKEN"
```

```json
{
  "_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
}
```

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`](https://hub.main-team.org/api/reference/create-signin-link) returns a URL that signs one of your students into one organization's panel:

```json
{
  "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 `302` to 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 `redirect` to a path.** The optional `redirect` must 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](https://hub.main-team.org/api/guides/sign-in-links) and [Send a student to the panel](https://hub.main-team.org/api/tutorials/send-student-to-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`](https://hub.main-team.org/api/reference/set-student-password):

- Your account needs `auth/signin` on `mto`. Without it, a registration carrying a password is refused with `403 forbidden` before 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.

```js
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
<?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](https://hub.main-team.org/api/guides/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 carry `Strict-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 `401` with 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 `404` exactly 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-Options` and 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_id`s 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](https://hub.main-team.org/api/support).

## Checklist

- [ ] The `apiSecret` lives 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.
- [ ] `Authorization` headers 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 `disallow` on 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.
