# Handle tokens in production

> Cache and renew your signed tokens, survive clock drift, run several servers, and revoke tokens on shutdown or after a leak. Node.js and PHP.

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

| Part | Rule |
|---|---|
| Algorithm | `HS256` (HMAC with SHA-256), and nothing else |
| Header `kid` | Your `apiKey`, exactly as issued: `key_` followed by 24 characters |
| Payload `sub` | Your `apiKey` again. It must equal `kid` |
| Payload `iat` | Issued-at time, in **seconds** since the Unix epoch. Required |
| Payload `exp` | Expiry time, in seconds. Required, and after `iat` |
| Lifetime | `exp - iat` at most **3600** seconds |
| Clock tolerance | `iat` may be up to 30 seconds ahead of the server's clock. A token is still accepted up to 30 seconds after `exp` |
| Signing key | Your `apiSecret`, the whole string as issued, `secret_` prefix included. Do not decode it |
| Transport | `Authorization: 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](#when-you-get-a-401) 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:

| Input | Value |
|---|---|
| `apiKey` | `key_Q2xpZW50RXhhbXBsZUtleTAx` |
| `apiSecret` | `secret_test-vector-01` |
| Header | `{"alg":"HS256","typ":"JWT","kid":"key_Q2xpZW50RXhhbXBsZUtleTAx"}` |
| Payload | `{"sub":"key_Q2xpZW50RXhhbXBsZUtleTAx","iat":1767225600,"exp":1767226500}` |

```text [Expected token]
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleV9RMnhwWlc1MFJYaGhiWEJzWlV0bGVUQXgifQ.eyJzdWIiOiJrZXlfUTJ4cFpXNTBSWGhoYlhCc1pVdGxlVEF4IiwiaWF0IjoxNzY3MjI1NjAwLCJleHAiOjE3NjcyMjY1MDB9.LLLSuqwj4CNrhH1ffsGSvhRjTa7Gl86prmkyYTaymbk
```

The Node.js and PHP code on this page, the shell recipe in
[Register and apply](https://hub.main-team.org/api/tutorials/register-and-apply#a-token-for-curl), 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

```js [token-manager.mjs]
// 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:

```js [service.mjs]
// 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]
<?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 [example.php]
<?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 is | Effect |
|---|---|
| Right, or off by a few seconds | Nothing |
| More than 30 seconds **fast** | `iat` looks like the future. **Every token is refused** with `401` |
| **Slow** | Tokens 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.

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

```json [Response 200]
{
  "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.

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

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](https://hub.main-team.org/api/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](https://hub.main-team.org/api/environments#sandbox).

`403 forbidden` is not a token problem: the token was accepted and your account lacks a permission.
See [Permissions](https://hub.main-team.org/api/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:

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

```json [Response 200]
{
  "_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](https://hub.main-team.org/api/guides/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

- [Authentication](https://hub.main-team.org/api/authentication): the concepts behind this page.
- [Security](https://hub.main-team.org/api/security): secrets, sign-in links and students' data.
- [Build your own client](https://hub.main-team.org/api/clients/build-your-own): the other facts a client needs.
- Reference: [getCurrentApiAccount](https://hub.main-team.org/api/reference/get-current-api-account),
  [revokeToken](https://hub.main-team.org/api/reference/revoke-token), [getHealth](https://hub.main-team.org/api/reference/get-health).
