Skip to content
API documentation
View as MarkdownOpen in Claude

Tutorials

Handle tokens in production

There is no token endpoint on this API. Your server signs its own tokens with your apiSecret, which means the correctness and safety of every token is up to your code. In this tutorial you build a token manager that signs correct tokens, reuses each one until shortly before it expires, corrects for a drifting clock, and revokes a token when you are done with it. You also plan for several servers and for the day a secret leaks.

The rules your token must meet

PartRule
AlgorithmHS256 (HMAC with SHA-256), and nothing else
Header kidYour apiKey, exactly as issued: key_ followed by 24 characters
Payload subYour apiKey again. It must equal kid
Payload iatIssued-at time, in seconds since the Unix epoch. Required
Payload expExpiry time, in seconds. Required, and after iat
Lifetimeexp - iat at most 3600 seconds
Clock toleranceiat may be up to 30 seconds ahead of the server's clock. A token is still accepted up to 30 seconds after exp
Signing keyYour apiSecret, the whole string as issued, secret_ prefix included. Do not decode it
TransportAuthorization: Bearer <token>

Every failure answers the same 401 unauthorized with the same message: Authentication is required or the provided credentials are invalid. The response never says which rule failed, so that a leaked key or token tells its finder nothing. This page's 401 checklist is how you find out.

A test vector

Sign a token with these exact inputs and compare. If your code produces a different string, fix it before you call the API:

InputValue
apiKeykey_Q2xpZW50RXhhbXBsZUtleTAx
apiSecretsecret_test-vector-01
Header{"alg":"HS256","typ":"JWT","kid":"key_Q2xpZW50RXhhbXBsZUtleTAx"}
Payload{"sub":"key_Q2xpZW50RXhhbXBsZUtleTAx","iat":1767225600,"exp":1767226500}
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleV9RMnhwWlc1MFJYaGhiWEJzWlV0bGVUQXgifQ.eyJzdWIiOiJrZXlfUTJ4cFpXNTBSWGhoYlhCc1pVdGxlVEF4IiwiaWF0IjoxNzY3MjI1NjAwLCJleHAiOjE3NjcyMjY1MDB9.LLLSuqwj4CNrhH1ffsGSvhRjTa7Gl86prmkyYTaymbk

The Node.js and PHP code on this page, the shell recipe in Register and apply, and the jsonwebtoken npm package with { algorithm: 'HS256', keyid: apiKey } all produce this string. The key and secret above are examples only and work nowhere.

The strategy: sign locally, reuse, renew early

  • Choose a lifetime between a few minutes and the 3600-second maximum. This page uses 900 seconds (15 minutes). Shorter lifetimes limit the damage if a token leaks; longer ones sign less often. Signing is a single HMAC, so the cost is negligible either way.
  • Reuse the token for every request until it is about to expire. Fewer live tokens make revoking one meaningful, and they make your logs easier to read.
  • Renew 60 seconds before exp, so a token never expires while a request is on its way.
  • On a 401, throw the cached token away, sign a new one, and retry once. If the retry also fails, stop and alert: a second 401 is not a timing problem.

Node.js: a token manager

// token-manager.mjs: signed tokens for the Main Team API, reused and renewed early.
// Node.js 20 or newer, no dependencies.
import { createHmac } from 'node:crypto';

export const BASE_URL = 'https://api.main-team.org/v1';
const API_KEY_FORMAT = /^key_[A-Za-z0-9_-]{24}$/;
const MAX_LIFETIME = 3600;

const b64url = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');

export class TokenManager {
  #apiKey;
  #apiSecret;
  #lifetime;
  #renewMargin;
  #skew = 0; // server clock minus local clock, in seconds
  #current = null; // { token, exp }

  constructor({ apiKey, apiSecret, lifetime = 900, renewMargin = 60 }) {
    if (!API_KEY_FORMAT.test(apiKey ?? '')) {
      throw new Error('apiKey is not in the issued format (key_ and 24 characters). Are key and secret swapped?');
    }
    if (typeof apiSecret !== 'string' || !apiSecret.startsWith('secret_')) {
      throw new Error('apiSecret is not in the issued format (it starts with secret_).');
    }
    if (!Number.isInteger(lifetime) || lifetime > MAX_LIFETIME || lifetime <= renewMargin) {
      throw new Error(`lifetime must be whole seconds, above ${renewMargin} and at most ${MAX_LIFETIME}.`);
    }
    this.#apiKey = apiKey;
    this.#apiSecret = apiSecret;
    this.#lifetime = lifetime;
    this.#renewMargin = renewMargin;
  }

  /** Corrects every future token for a clock that is off. See measureSkew(). */
  setSkew(seconds) {
    this.#skew = seconds;
    this.#current = null;
  }

  /** A valid token: the cached one, or a fresh one near expiry. */
  get() {
    const now = Math.floor(Date.now() / 1000) + this.#skew;
    if (this.#current && this.#current.exp - this.#renewMargin > now) return this.#current.token;
    const exp = now + this.#lifetime;
    const header = b64url({ alg: 'HS256', typ: 'JWT', kid: this.#apiKey });
    const payload = b64url({ sub: this.#apiKey, iat: now, exp });
    const signature = createHmac('sha256', this.#apiSecret)
      .update(`${header}.${payload}`)
      .digest('base64url');
    this.#current = { token: `${header}.${payload}.${signature}`, exp };
    return this.#current.token;
  }

  /** The cached token, if any, without signing one. */
  peek() {
    return this.#current?.token ?? null;
  }

  /** Forgets the cached token, after a 401 or after revoking it. */
  invalidate() {
    this.#current = null;
  }
}

/** Server clock minus local clock, in whole seconds, from the Date header of the health check. */
export async function measureSkew() {
  const before = Date.now();
  const res = await fetch(`${BASE_URL}/health`, { signal: AbortSignal.timeout(5000) });
  const after = Date.now();
  await res.text();
  const server = Date.parse(res.headers.get('date') ?? '');
  return Number.isNaN(server) ? 0 : Math.round((server - (before + after) / 2) / 1000);
}

/** Sends a request with the managed token, retrying a 401 once with a fresh one. */
export async function authorizedFetch(tokens, path, init = {}) {
  for (let attempt = 1; ; attempt++) {
    const res = await fetch(`${BASE_URL}${path}`, {
      ...init,
      headers: { ...init.headers, Authorization: `Bearer ${tokens.get()}` },
      signal: AbortSignal.timeout(30_000),
    });
    if (res.status !== 401 || attempt === 2) return res;
    await res.text();
    tokens.invalidate();
  }
}

/** Revokes a token. Returns how many seconds the revocation is held. */
export async function revokeToken(token) {
  const res = await fetch(`${BASE_URL}/api-account/revoke-token`, {
    method: 'POST',
    headers: { Authorization: `Bearer ${token}` },
    signal: AbortSignal.timeout(10_000),
  });
  const body = await res.json().catch(() => null);
  if (!res.ok) throw new Error(`revoke-token answered ${res.status} ${body?.error?.code ?? ''}`);
  return body.data.expiresIn;
}

Wire it into a long-running service. The service checks its clock at start-up, confirms its credentials, and revokes its token when it shuts down:

// service.mjs: a long-running service that uses the token manager.
import { authorizedFetch, measureSkew, revokeToken, TokenManager } from './token-manager.mjs';

const tokens = new TokenManager({
  apiKey: process.env.MTO_API_KEY,
  apiSecret: process.env.MTO_API_SECRET,
});

// 1. The clock. A few seconds is harmless; more than 30 breaks every token.
const skew = await measureSkew();
if (Math.abs(skew) > 10) console.warn(`Local clock is off by ${skew} s; correcting. Fix NTP on this host.`);
tokens.setSkew(skew);

// 2. The credentials, once, at start-up.
const res = await authorizedFetch(tokens, '/api-account/validate-me');
if (!res.ok) throw new Error(`Credentials refused: ${res.status}`);
const account = await res.json();
console.log(`API account ${account.companyName}, ${account.roles.length} roles`);

// 3. Revoke the token on the way out, so it cannot be replayed from a log or a dump.
process.once('SIGTERM', async () => {
  const token = tokens.peek();
  if (token) {
    try {
      console.log(`Token revoked for ${await revokeToken(token)} s`);
    } catch (error) {
      console.warn(`Could not revoke the token: ${error.message}`);
    }
  }
  process.exit(0);
});

// ... the rest of your service calls authorizedFetch(tokens, path, init) ...

PHP: sharing a token between requests

Under PHP-FPM or Apache, every request starts with empty memory, so an in-process cache lives for one request only. There are two sound choices:

  • Sign a token per request. It is one HMAC, so this costs almost nothing. It is the simplest option and fine for most sites.
  • Share one token through APCu, so every request on the machine reuses it until it nears expiry. This keeps the number of live tokens small, which makes revoking one meaningful.

The class below does the second when APCu is available and falls back to the first when it is not. APCu is usually disabled for command-line PHP, so scripts and cron jobs sign per run.

<?php
// token-cache.php: signed tokens for the Main Team API, shared through APCu when available.
// PHP 8.1 or newer with ext-curl and ext-json. APCu is optional.
declare(strict_types=1);

final class MainTeamTokens
{
    public const BASE_URL = 'https://api.main-team.org/v1';
    private const MAX_LIFETIME = 3600;

    private ?array $local = null; // ['token' => string, 'exp' => int]

    public function __construct(
        private string $apiKey,
        private string $apiSecret,
        private int $lifetime = 900,
        private int $renewMargin = 60,
        private int $skew = 0, // server clock minus local clock, in seconds
    ) {
        if (!preg_match('/^key_[A-Za-z0-9_-]{24}$/', $apiKey)) {
            throw new InvalidArgumentException('apiKey is not in the issued format (key_ and 24 characters). Are key and secret swapped?');
        }
        if (!str_starts_with($apiSecret, 'secret_')) {
            throw new InvalidArgumentException('apiSecret is not in the issued format (it starts with secret_).');
        }
        if ($lifetime > self::MAX_LIFETIME || $lifetime <= $renewMargin) {
            throw new InvalidArgumentException("lifetime must be above $renewMargin and at most " . self::MAX_LIFETIME . ' seconds.');
        }
    }

    private static function b64url(string $bytes): string
    {
        return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
    }

    private function cacheKey(): string
    {
        return 'mainteam-token:' . $this->apiKey;
    }

    private static function apcu(): bool
    {
        return function_exists('apcu_enabled') && apcu_enabled();
    }

    /** A valid token: the shared one, or a fresh one near expiry. */
    public function get(): string
    {
        $now = time() + $this->skew;
        $cached = $this->local;
        if ($cached === null && self::apcu()) {
            $fetched = apcu_fetch($this->cacheKey(), $hit);
            $cached = $hit && is_array($fetched) ? $fetched : null;
        }
        if ($cached !== null && $cached['exp'] - $this->renewMargin > $now) {
            $this->local = $cached;
            return $cached['token'];
        }

        $exp = $now + $this->lifetime;
        $header = self::b64url(json_encode(['alg' => 'HS256', 'typ' => 'JWT', 'kid' => $this->apiKey]));
        $payload = self::b64url(json_encode(['sub' => $this->apiKey, 'iat' => $now, 'exp' => $exp]));
        $signature = self::b64url(hash_hmac('sha256', "$header.$payload", $this->apiSecret, true));
        $this->local = ['token' => "$header.$payload.$signature", 'exp' => $exp];
        if (self::apcu()) {
            apcu_store($this->cacheKey(), $this->local, $this->lifetime - $this->renewMargin);
        }
        return $this->local['token'];
    }

    /** Forgets the token, after a 401 or after revoking it. */
    public function invalidate(): void
    {
        $this->local = null;
        if (self::apcu()) {
            apcu_delete($this->cacheKey());
        }
    }

    /** Server clock minus local clock, in whole seconds, from the Date header of the health check. */
    public static function measureSkew(): int
    {
        $date = null;
        $ch = curl_init(self::BASE_URL . '/health');
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 5,
            CURLOPT_HEADERFUNCTION => function ($ch, string $line) use (&$date): int {
                if (stripos($line, 'date:') === 0) {
                    $date = trim(substr($line, 5));
                }
                return strlen($line);
            },
        ]);
        $before = microtime(true);
        curl_exec($ch);
        $after = microtime(true);
        curl_close($ch);
        $server = $date === null ? false : strtotime($date);
        return $server === false ? 0 : (int) round($server - ($before + $after) / 2);
    }

    /** Revokes a token. Returns how many seconds the revocation is held. */
    public static function revoke(string $token): int
    {
        $ch = curl_init(self::BASE_URL . '/api-account/revoke-token');
        curl_setopt_array($ch, [
            CURLOPT_POSTFIELDS => '',
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $token],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
        ]);
        $text = curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        $body = is_string($text) ? json_decode($text, true) : null;
        if ($status !== 200 || !isset($body['data']['expiresIn'])) {
            throw new RuntimeException("revoke-token answered $status " . ($body['error']['code'] ?? ''));
        }
        return (int) $body['data']['expiresIn'];
    }
}

Using it, with a retry on 401:

<?php
declare(strict_types=1);
require __DIR__ . '/token-cache.php';

$tokens = new MainTeamTokens(getenv('MTO_API_KEY') ?: '', getenv('MTO_API_SECRET') ?: '');

function getJson(MainTeamTokens $tokens, string $path): array
{
    for ($attempt = 1; ; $attempt++) {
        $ch = curl_init(MainTeamTokens::BASE_URL . $path);
        curl_setopt_array($ch, [
            CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $tokens->get()],
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_CONNECTTIMEOUT => 5,
            CURLOPT_TIMEOUT => 30,
        ]);
        $text = (string) curl_exec($ch);
        $status = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
        curl_close($ch);
        if ($status === 401 && $attempt === 1) {
            $tokens->invalidate(); // sign a fresh token and try once more
            continue;
        }
        if ($status !== 200) {
            throw new RuntimeException("GET $path answered $status");
        }
        return json_decode($text, true) ?? [];
    }
}

$account = getJson($tokens, '/api-account/validate-me');
echo "API account {$account['companyName']}\n";

Clock drift

Your token's iat and exp come from your server's clock, and the API checks them against its own. It allows 30 seconds of disagreement in either direction:

Your clock isEffect
Right, or off by a few secondsNothing
More than 30 seconds fastiat looks like the future. Every token is refused with 401
SlowTokens expire earlier in real time, by the amount you are slow. Renewing 60 seconds early plus the 30-second tolerance covers up to 90 seconds; beyond that, requests near the end of each token's life get 401

Keep your servers synchronized with NTP. As a safety net, both classes above can measure the difference from the Date header of GET /v1/health, which needs no token and doesn't count against your rate limit, and then correct every token they sign. Log a warning when the difference exceeds 10 seconds, because a drifting clock affects more than this API.

Revoking a token

POST /v1/api-account/revoke-token revokes the token it is sent with. Revoke a token:

  • when a long-running process shuts down (good hygiene, not a requirement);
  • when a token has been exposed: pasted into a ticket, printed in a log, left in a crash dump;
  • when you gave a token to a separate process and that process is finished.
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": 812 }
}
  • From that moment the token answers 401 on every request.
  • expiresIn is how many seconds the revocation is held: the token's remaining lifetime plus the 30-second tolerance. After that the token would be refused as expired anyway.
  • Revoking one token affects nothing else. Your account, your secret and every other token keep working, and you can sign a new token straight away.
  • Revoking needs the api/* permission on mto, the same one validate-me needs. Without it the answer is 403, so ask your operator for it before you depend on revocation.
  • A 400 bad_request with No token provided. or Invalid token format. means the token could not be read. You will not see it for a token the API has just accepted.

Note

Revocation works on one token, not on the account. To stop every token at once you need the account deactivated, which only an operator can do.

When the secret itself leaks

If your apiSecret is exposed, revoking tokens does not help: whoever holds the secret can sign new ones.

  1. Contact your operator at once (or support at info@main-team.org) and ask for the account to be deactivated. Every token signed with the secret stops working within 60 seconds.
  2. Plan the replacement with them. Secrets cannot be rotated; a new secret comes with a new account and a new apiKey. Students belong to the account that registered them, so a new account does not see the old account's students. Agree how to handle that before the new account is issued.
  3. Find out how it leaked before you install the new secret in the same place.

Security

Keep the secret where only your server-side code can read it: a secret manager, or an environment variable set by your deployment. Never commit it, never put it in a browser or a mobile app, and never write it to a log. Redact the Authorization header in your request logs too: a token is a credential for as long as it lives.

Several servers

  • Each server may sign its own tokens with the same key and secret. The API needs no shared state from you: any correctly signed token is accepted wherever it was made.
  • The rate limit is shared. It counts per account and per operation (100 requests in 60 seconds), not per server, so adding servers does not add capacity. Coordinate heavy jobs. See Rate limits.
  • Give separate workers separate tokens, so you can revoke one worker's token without touching the others.
  • Consider one signing service. If many internal services call the API, let one small service hold the secret and hand out short-lived tokens to the others. The secret then lives on one machine instead of many.

Role changes and deactivations made by an operator reach the API within 60 seconds, whichever server you call from.

When you get a 401

Retry once with a freshly signed token. If that also fails, work through this list. The server logs the real reason against the request_id in the error body, so quote the id if you ask for help.

  • The key and secret are the right way round: the key starts with key_, the secret with secret_.
  • Neither value is truncated or has whitespace or quotes around it from your configuration.
  • The header reads exactly Authorization: Bearer <token>: Bearer capitalized, then one space.
  • The header has "alg": "HS256" and "kid" set to the key. Some libraries need an explicit option for kid.
  • The payload has sub equal to the key.
  • iat and exp are both present and in seconds. Milliseconds make the lifetime look enormous and iat look like the far future.
  • exp - iat is 3600 or less.
  • Your clock is within 30 seconds of real time. Compare it with GET /v1/health.
  • You did not revoke this token earlier.
  • The account is still active. An operator may have deactivated it.
  • You are calling the environment the account belongs to. Sandbox accounts are separate: a sandbox key gets 401 on production, and a production key gets 401 on the sandbox. See Environments.

403 forbidden is not a token problem: the token was accepted and your account lacks a permission. See Permissions.

Check your setup with validate-me

GET /v1/api-account/validate-me returns your account as the API sees it. That makes it a good start-up check and an easy thing to put on a status page:

curl -s https://api.main-team.org/v1/api-account/validate-me \
  -H "Authorization: Bearer $TOKEN"
{
  "_id": "66d0c2f4a1b2c3d4e5f60718",
  "apiKey": "key_Q2xpZW50RXhhbXBsZUtleTAx",
  "companyName": "Example Schools Ltd",
  "scopes": [],
  "roles": [
    { "effect": "allow", "action": "api/*", "target": "mto" },
    { "effect": "allow", "action": "*/read", "target": "*" }
  ],
  "isActive": true
}

The account comes back bare, without the { success, message, data } envelope every other JSON response has. The secret is never included. See API account.

Checklist

  • The secret is read from a secret store or the environment, and never logged.
  • Tokens are signed on your server with HS256, kid and sub set to the key, iat and exp in seconds.
  • The lifetime is 3600 seconds or less, and tokens are renewed about a minute before exp.
  • A 401 triggers one retry with a fresh token, then an alert.
  • Servers keep their clocks synchronized, and a check warns when one drifts.
  • Long-running processes revoke their token on shutdown.
  • You know whom to call to deactivate the account if the secret leaks.

Next steps

Search the API documentation

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