Start here
Authentication
Every route except GET /v1/health needs a token. There is no login endpoint and no OAuth flow: you sign your own token with the secret you were issued, and the API checks the signature. This page covers everything about that token: what it contains, how to sign it in Node.js, PHP and bash, how to reuse it, how to revoke it, and what to check when the API answers 401.
Your credentials
An operator creates your account and gives you two values, once.
apiKey | apiSecret | |
|---|---|---|
| What it is | Your account's public identifier | Your signing key |
| Format | key_ followed by exactly 24 characters (A-Z, a-z, 0-9, -, _), 28 characters in total | secret_ followed by a long random string |
| Where it goes | In every token, as the kid header and the sub claim | Nowhere. It never leaves your server. You use it only to compute signatures |
| Secret? | No. It identifies you; on its own it grants nothing | Yes. Anyone holding it can act as your account |
| Can you get it again? | Ask the operator | No. It is shown once and cannot be recovered |
| Can it be changed? | No | No. There is no rotation on an existing account |
The key and the secret are independent random values. You cannot derive one from the other.
Security
Keep the apiSecret in a secret manager or in your server's environment. Never commit it, log it, send it by email or chat, put it in a browser or mobile app, or paste it into a website (online JWT debuggers included). If you think it has leaked, follow If your secret leaks at once.
How a request is authenticated
You send the token in the Authorization header:
GET /v1/api-account/validate-me HTTP/1.1
Host: api.main-team.org
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleV83ZlF4MkxtTjlwUnRWdzNZekExYkM0ZEUifQ.eyJzdWIiOiJrZXlfN2ZReDJMbU45cFJ0VnczWXpBMWJDNGRFIiwiaWF0IjoxNzg5NDgxNjAwLCJleHAiOjE3ODk0ODUyMDB9.3nQ0k1Q9...
The API then:
- reads
kidfrom the token header and finds the active account with thatapiKey, - checks the signature with that account's secret, accepting HS256 only,
- checks the timestamps and that
subequalskid, - checks that the token has not been revoked.
If every check passes, the request continues to the permission check (see Permissions). If any check fails, the answer is the same 401, described in The 401 checklist.
Token anatomy
A token is a standard JSON Web Token (JWT): three base64url-encoded parts joined by dots, header.payload.signature.
Header
{
"alg": "HS256",
"typ": "JWT",
"kid": "key_7fQx2LmN9pRtVw3YzA1bC4dE"
}
| Field | Required | Rule |
|---|---|---|
alg | Yes | Must be HS256 (HMAC with SHA-256). Every other algorithm is refused, including none, HS384, HS512 and RS256. |
kid | Yes | Your apiKey, exactly as issued. This is how the API knows which account to check the signature against. |
typ | No | JWT is conventional, and most libraries add it. |
Payload (claims)
{
"sub": "key_7fQx2LmN9pRtVw3YzA1bC4dE",
"iat": 1789481600,
"exp": 1789485200
}
| Claim | Required | Rule |
|---|---|---|
sub | Yes | Your apiKey, the same value as kid. The signed subject has to match the key the token claims to be for. |
iat | Yes | Issued-at time, in whole seconds since the Unix epoch (UTC). It may be at most 30 seconds ahead of the API's clock. |
exp | Yes | Expiry time, in whole seconds since the Unix epoch. It must be later than iat and at most 3600 seconds (one hour) after it. |
nbf | No | Optional "not before". If present, it is honored, with the same 30 seconds of tolerance. |
Any other claims are ignored. Do not put anything sensitive in the payload: it is only base64url-encoded, and anyone who sees the token can read it.
Signature
The signature is HMAC-SHA256 over base64url(header) + "." + base64url(payload), with your apiSecret as the key.
Use the secret as text, exactly as issued: the whole string, secret_ prefix included, encoded as UTF-8. Do not base64-decode it, trim parts of it, or add a newline to it. JWT libraries do this correctly when you pass the secret as a plain string.
Lifetime rules at a glance
| Rule | Value |
|---|---|
Longest lifetime (exp − iat) | 3600 seconds |
| Shortest lifetime | exp must be later than iat |
Clock tolerance after exp | 30 seconds |
How far iat may be in the future | 30 seconds |
| Units | Seconds, never milliseconds |
Why these rules exist. A token is a bearer credential: whoever holds it can use it until it expires. Requiring exp, and capping the lifetime at one hour, limits what a leaked token is worth. The cap on a future iat stops anyone from minting a token "for next week" that would stay valid until then. The 30 seconds of tolerance absorb small clock differences between your servers and ours.
Sign a token
Pick the language you use. Each example produces a token that is valid for 3600 seconds, the maximum.
Node.js with jsonwebtoken
npm install jsonwebtoken
// token.js
const jwt = require('jsonwebtoken');
const API_KEY = process.env.MTO_API_KEY; // key_...
const API_SECRET = process.env.MTO_API_SECRET; // secret_... (the full value)
const LIFETIME_SECONDS = 3600; // the maximum the API accepts
function mintToken() {
const iat = Math.floor(Date.now() / 1000); // seconds, not milliseconds
return jwt.sign(
{ sub: API_KEY, iat, exp: iat + LIFETIME_SECONDS },
API_SECRET,
{
algorithm: 'HS256',
keyid: API_KEY, // written to the header as "kid"
},
);
}
module.exports = { mintToken };
Notes for jsonwebtoken:
- Put
iatandexpin the payload or use theexpiresInoption, not both. The library refuses a payloadexpcombined withexpiresIn. - The library adds
iatautomatically unless you passnoTimestamp: true. Never passnoTimestamp: the API requiresiat. - With another Node library, such as
jose, check that it setsiat. Injosethat means calling.setIssuedAt().
PHP with firebase/php-jwt
composer require firebase/php-jwt
<?php
// Token.php
require __DIR__ . '/vendor/autoload.php';
use Firebase\JWT\JWT;
function mintToken(string $apiKey, string $apiSecret, int $lifetime = 3600): string
{
$iat = time(); // seconds
return JWT::encode(
['sub' => $apiKey, 'iat' => $iat, 'exp' => $iat + $lifetime],
$apiSecret,
'HS256',
$apiKey // $keyId: written to the header as "kid"
);
}
$token = mintToken(getenv('MTO_API_KEY'), getenv('MTO_API_SECRET'));
JWT::encode($payload, $key, $alg, $keyId) sets kid from its fourth argument. If you leave it out, the token has no kid and the API refuses it.
bash with openssl
Useful for a quick test from a terminal, or in a shell script where you cannot install a library.
#!/usr/bin/env bash
# mint-token.sh: prints a token for MTO_API_KEY / MTO_API_SECRET
set -euo pipefail
: "${MTO_API_KEY:?set MTO_API_KEY}"
: "${MTO_API_SECRET:?set MTO_API_SECRET}"
lifetime=${1:-3600}
# base64url without padding, as JWT requires
b64url() { openssl base64 -e -A | tr '+/' '-_' | tr -d '='; }
now=$(date +%s)
header=$(printf '{"alg":"HS256","typ":"JWT","kid":"%s"}' "$MTO_API_KEY" | b64url)
payload=$(printf '{"sub":"%s","iat":%d,"exp":%d}' \
"$MTO_API_KEY" "$now" "$((now + lifetime))" | b64url)
signature=$(printf '%s.%s' "$header" "$payload" \
| openssl dgst -sha256 -hmac "$MTO_API_SECRET" -binary | b64url)
printf '%s.%s.%s\n' "$header" "$payload" "$signature"
export TOKEN=$(./mint-token.sh)
curl -s https://api.main-team.org/v1/api-account/validate-me \
-H "Authorization: Bearer $TOKEN"
Warning
openssl dgst -hmac takes the secret as a command-line argument, and on a shared machine other users can see command lines in the process list. Use this script on your own workstation or a single-tenant server, and use a JWT library in production code.
Any other language
Any JWT library that supports HS256 and lets you set the kid header works. There are no key pairs, certificates or PEM files. Check three things in your library: it signs with HS256, it writes kid into the header (not the payload), and it writes iat and exp in seconds. Build your own client lists everything else a client needs.
Reuse tokens: the cache pattern
You can sign a new token for every request; signing is cheap. But reusing one token until shortly before it expires is simpler to debug, and it means one fewer thing can go wrong on every call. The pattern:
- Keep the current token and its
expin memory. - Before each request, if the token expires within the next 60 seconds, sign a new one.
- Otherwise reuse it.
A 60-second margin makes sure a token never expires while a request is in flight, and absorbs small clock differences.
Node.js
// token-cache.js
const jwt = require('jsonwebtoken');
const API_KEY = process.env.MTO_API_KEY;
const API_SECRET = process.env.MTO_API_SECRET;
const LIFETIME_SECONDS = 3600;
const REFRESH_MARGIN_SECONDS = 60;
let current = null; // { token, exp }
function getToken() {
const now = Math.floor(Date.now() / 1000);
if (current && current.exp - REFRESH_MARGIN_SECONDS > now) {
return current.token;
}
const iat = now;
const exp = iat + LIFETIME_SECONDS;
const token = jwt.sign({ sub: API_KEY, iat, exp }, API_SECRET, {
algorithm: 'HS256',
keyid: API_KEY,
});
current = { token, exp };
return token;
}
async function api(path, { method = 'GET', body } = {}) {
const res = await fetch(`https://api.main-team.org/v1${path}`, {
method,
headers: {
Authorization: `Bearer ${getToken()}`,
...(body !== undefined ? { 'Content-Type': 'application/json' } : {}),
},
body: body !== undefined ? JSON.stringify(body) : undefined,
});
const requestId = res.headers.get('x-request-id');
// Error bodies are JSON too, but a proxy in between can answer with HTML.
const text = await res.text();
let payload = null;
try {
payload = text ? JSON.parse(text) : null;
} catch {
payload = null;
}
if (!res.ok) {
const err = new Error(`${res.status} ${payload?.error?.code}: ${payload?.error?.message}`);
err.status = res.status;
err.code = payload?.error?.code;
err.requestId = payload?.error?.request_id ?? requestId;
throw err;
}
return payload;
}
module.exports = { getToken, api };
PHP
A PHP-FPM worker does not keep variables between requests, so an in-memory cache only helps inside one long-running script such as a queue worker or a cron job. For web requests you have two good options: sign a fresh token per request, or keep the token in a shared cache such as APCu. The class below does the second when APCu is available and falls back to the first.
<?php
use Firebase\JWT\JWT;
final class MtoToken
{
private const LIFETIME = 3600;
private const MARGIN = 60;
private const CACHE_KEY = 'mto_api_token';
private ?string $token = null;
private int $exp = 0;
public function __construct(
private readonly string $apiKey,
private readonly string $apiSecret,
) {}
public function get(): string
{
$now = time();
if ($this->token !== null && $this->exp - self::MARGIN > $now) {
return $this->token;
}
if (function_exists('apcu_fetch')) {
$cached = apcu_fetch(self::CACHE_KEY);
if (is_array($cached) && $cached['exp'] - self::MARGIN > $now) {
[$this->token, $this->exp] = [$cached['token'], $cached['exp']];
return $this->token;
}
}
$iat = $now;
$this->exp = $iat + self::LIFETIME;
$this->token = JWT::encode(
['sub' => $this->apiKey, 'iat' => $iat, 'exp' => $this->exp],
$this->apiSecret,
'HS256',
$this->apiKey
);
if (function_exists('apcu_store')) {
// Only the token is cached, never the secret.
apcu_store(self::CACHE_KEY, ['token' => $this->token, 'exp' => $this->exp],
$this->exp - $now - self::MARGIN);
}
return $this->token;
}
}
$tokens = new MtoToken(getenv('MTO_API_KEY'), getenv('MTO_API_SECRET'));
$ch = curl_init('https://api.main-team.org/v1/api-account/validate-me');
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $tokens->get()],
]);
$account = json_decode(curl_exec($ch), true);
Several servers
Each of your servers or workers can sign its own tokens with the same key and secret. There is no limit on how many valid tokens an account has at one time, and nothing needs to be shared between machines. The rate limit belongs to the account, so it is shared however many servers you run (see Rate limits).
Clocks
Your token's iat and exp come from your server's clock and are checked against ours:
| If your clock is | Effect |
|---|---|
| Within 30 seconds of real time | Works |
| More than 30 seconds ahead | Every new token is refused, because iat is in the future |
| Behind | Tokens run out sooner than you expect, and a token looks expired as soon as your clock is more than an hour and 30 seconds behind |
Run NTP on every machine that signs tokens. A 401 that starts suddenly on one server but not on others usually means a clock problem.
The token handling tutorial covers these patterns in more depth, including revoking on shutdown.
Check a token: validate-me
GET /v1/api-account/validate-me returns the account the token belongs to. Use it to check a new setup, to check your integration from monitoring, and to read your current roles.
curl -s https://api.main-team.org/v1/api-account/validate-me \
-H "Authorization: Bearer $TOKEN"
{
"_id": "66f1a2b3c4d5e6f7a8b9c0d1",
"apiKey": "key_7fQx2LmN9pRtVw3YzA1bC4dE",
"companyName": "Northwind Learning Ltd",
"scopes": [],
"roles": [
{ "effect": "allow", "action": "api/*", "target": "mto" },
{ "effect": "allow", "action": "*/read", "target": "*" },
{ "effect": "allow", "action": "student/*", "target": "mto" }
],
"isActive": true
}
| Field | Meaning |
|---|---|
_id | Your account's id. The students you register belong to this id. A role whose authorized is set must name this id (see Permissions). |
apiKey | Your key, the same as the token's kid. |
companyName | The name the operator gave your account. |
scopes | Informational labels set by the operator. They are not used for access decisions; roles are. |
roles | Your permissions: a list of { effect, action, target, authorized? }. See Permissions. |
isActive | Always true here, because a deactivated account cannot authenticate at all. |
The response never contains your apiSecret.
No envelope. Unlike every other JSON route, validate-me returns the account object on its own, not inside { "success", "message", "data" }. If your client unwraps data automatically, make an exception for this route.
Permission. validate-me needs the action api/* on mto. Only a role action of api/*, */* or * grants it. A token that is fine but lacks this role gets 403 forbidden, not 401. That difference is useful: a 403 here proves your token is valid.
Reference: getCurrentApiAccount. More uses: API account guide.
Revoke a token: revoke-token
POST /v1/api-account/revoke-token revokes the token you send it with. From then on, that token answers 401 on every request. Nothing else changes: your account, your secret and your other tokens keep working, and you can sign a new token at once.
curl -s -X POST https://api.main-team.org/v1/api-account/revoke-token \
-H "Authorization: Bearer $TOKEN"
{
"success": true,
"message": "Token revoked successfully",
"data": { "expiresIn": 3412 }
}
expiresIn is how many seconds the revocation is kept: the token's remaining lifetime plus the 30 seconds of clock tolerance, at least 1 and at most 3660. After that, the token is refused because it has expired, so no revocation needs to be kept.
The request has no body. The only token it can revoke is the one in its own Authorization header, so to revoke a token you must still hold it.
| Answer | When |
|---|---|
200 | The token is revoked |
401 unauthorized | The token was already invalid: expired, revoked, or otherwise refused. There is nothing left to revoke |
403 forbidden | Your account lacks api/* on mto, the permission this route needs, like validate-me |
400 bad_request | No token provided. or Invalid token format.: the token could not be read or carries no exp. A token that passed authentication always has one, so you should not see this |
When to revoke:
- On shutdown of a long-running worker, so that a token left in memory or in a crash dump is worthless.
- When a token may have leaked, for example if it was written to a log or pasted into a ticket.
- When you rotate to a new token early for any reason.
Note
HS256 signing is deterministic. Two tokens with the same header and the same claims are the same string, so if two of your workers sign { sub, iat, exp } in the same second, they hold one token, and revoking it cuts off both. If your workers revoke on shutdown, give each token something unique, for example a random jti claim. The API ignores jti, but it makes every token distinct.
Revoking a token does not protect you from a leaked secret: whoever holds the secret can sign new tokens. For that, see Changes to your account and If your secret leaks.
Reference: revokeToken.
Changes to your account
Operators manage accounts; there is no route to change your own. What you can expect:
| Change | Who makes it | When it takes effect |
|---|---|---|
| Your roles are changed | Operator | Within 60 seconds. Roles belong to your account, not to the token, so tokens you already hold pick up the new roles; you do not need to sign new ones |
| Your account is deactivated | Operator | Within 60 seconds, every token of the account answers 401 |
| A single token is revoked | You, with revoke-token | At once |
| Your secret is changed | Not possible | A new secret means a new account |
If your secret leaks
- Email info@main-team.org from the address you normally use with us. Ask for the account to be deactivated, and give its
apiKey(never the secret). Within 60 seconds of deactivation, every token signed with the secret stops working. - Revoke the tokens you hold, if you want to cut them off before the operator acts. This does not stop new tokens being signed with the leaked secret; only deactivation does.
- Find the cause and fix it (a committed
.envfile, a log line, a shared screen) before you get new credentials. - Agree the replacement with the operator. A new account comes with a new key and secret. Students belong to the account that registered them, so a new account does not see the students the old account registered. Discuss with the operator how to handle them before you switch.
The 401 checklist
Every authentication failure answers exactly this:
{
"error": {
"code": "unauthorized",
"message": "Authentication is required or the provided credentials are invalid.",
"documentation_url": "https://hub.main-team.org/api/errors#unauthorized",
"request_id": "0b5c6d0e-8f7a-4b1c-9d2e-3f4a5b6c7d8e"
}
}
The API never says which check failed. A distinct message would tell someone holding a stolen key whether it is live, or a stolen token whether it was revoked. Our logs do record the reason against the request_id, so if you are stuck after this checklist, send us the request_id (never the token).
Go through the list in order. It follows the order in which the API checks.
| # | Check | Typical mistake |
|---|---|---|
| 1 | The header is Authorization: Bearer <token> | bearer in lower case, two spaces, quotes around the token, a missing header after a proxy or redirect |
| 2 | The token is three base64url parts separated by dots, and its header and payload are JSON | A line break or whitespace inside the token; the token URL-encoded twice |
| 3 | The header has kid, and it equals your apiKey exactly: key_ plus 24 characters | kid in the payload instead of the header; the key trimmed or with a trailing newline; a key from another environment |
| 4 | Your account is active | The account was deactivated |
| 5 | alg is HS256 | The library defaulted to another algorithm |
| 6 | The signature uses your full apiSecret as text | The secret_ prefix dropped; a trailing newline from a file; the secret base64-decoded first; a secret from another account |
| 7 | The token has not expired (30 seconds of grace after exp), and any nbf has passed | A cached token used past its exp; the clock runs slow |
| 8 | iat and exp are both present, as numbers | noTimestamp set; claims sent as strings |
| 9 | iat and exp are in seconds | Date.now() (milliseconds) used directly |
| 10 | iat is not more than 30 seconds ahead of real time | The server clock runs fast; no NTP |
| 11 | exp is later than iat, by at most 3600 seconds | A 24-hour token; expiresIn: '2h' |
| 12 | sub equals kid | sub set to a user id or your company name |
| 13 | The token has not been revoked | The token was revoked at shutdown and then reused |
Decode your token locally
Inspect your token on your own machine. Never paste it into a website.
node -e '
const [h, p] = process.argv[1].split(".");
console.log(JSON.parse(Buffer.from(h, "base64url")));
const claims = JSON.parse(Buffer.from(p, "base64url"));
const now = Math.floor(Date.now() / 1000);
console.log(claims);
console.log({ lifetime: claims.exp - claims.iat, iatVsNow: claims.iat - now, expiresIn: claims.exp - now });
' "$TOKEN"
{ alg: 'HS256', typ: 'JWT', kid: 'key_7fQx2LmN9pRtVw3YzA1bC4dE' }
{ sub: 'key_7fQx2LmN9pRtVw3YzA1bC4dE', iat: 1789481600, exp: 1789485200 }
{ lifetime: 3600, iatVsNow: -12, expiresIn: 3588 }
A good token shows alg: 'HS256', kid and sub both equal to your key, lifetime of 3600 or less, and iatVsNow of 30 or less. If all of that is right and you still get 401, the signature or the secret is the problem (check 6).
Is it 401 or 403?
| Status | Meaning | What helps |
|---|---|---|
401 unauthorized | The API does not accept the token | Fix the token, using the checklist above |
403 forbidden | The token is fine, but your account has no role for this route | Ask the operator for the role. A new token does not help. See Permissions |
A request refused with 401, or with 403 Insufficient role permissions, does not count against your rate limit.
What not to do
- Do not call the API from a browser or an app. The API sends no CORS headers, and the secret would be exposed. See Environments.
- Do not share one token across environments or accounts. Each token is tied to one key.
- Do not log tokens. Redact the
Authorizationheader in your HTTP logs. A token is a credential for up to an hour. - Do not make tokens longer-lived than you need. One hour is the maximum; shorter is fine.
More on protecting your integration: Security.