# Authentication (hosted service)

This guide applies to servers that run with `WHEREBOX_AUTH=keys`, such as the hosted service. A self-hosted server with the default `WHEREBOX_AUTH=none` is open and ignores everything below; the `apikey`, `sig` and `exp` parameters are accepted and skipped, so the same URLs work against both.

Real use needs a key. There are two kinds, and which one you use depends on where the request is made.

**Trying it without a key.** A server can offer a small anonymous demo allowance (`WHEREBOX_ANONYMOUS`, on for the hosted service): `GET` map requests and searches without any key work, limited per network (an IPv4 address or an IPv6 /64) and per day, as listed on the [fair use](/docs/guides/fair-use.md#the-limits) page. That is what makes the playground and the docs examples work before you sign up. It is a demo, not a free tier: `POST /v1/map`, road routing and larger images need a key, and once the allowance is used up the error says where to sign up for a free one. A key that is present is always checked; a malformed or unknown key is a `401`, never a fallback to the demo allowance.

## Keys

- A **secret key** (`sk_` followed by 32 characters) goes in the `Authorization: Bearer sk_…` header. Use it from your server.
- A **publishable key** (`pk_` followed by 32 characters) goes in the `apikey` parameter of a signed URL. Use it for `<img>` tags in pages and emails.

Treat a **secret key** like a password. It authorizes any request for your account, so it must never appear in a URL, a web page, a mobile app or a log. If you put one in a URL, the server refuses the request with [`forbidden`](/docs/errors.md#forbidden), and you should rotate the key, because URLs end up in server logs, browser history and `Referer` headers.

A **publishable key** is designed to be visible. It only works together with a signature, and the signature is what protects you. Each publishable key comes with a **signing secret**, which stays on your server and signs the URLs you embed in pages. Operators get both when they create the key (see [admin commands](#admin-commands)). The token is shown once, so store it right away.

Publishable and secret keys are not interchangeable. A `pk_` key in the `Authorization` header and a signature-less `pk_` key in a URL are both refused with `forbidden`. Sending both a header and `apikey` is refused with [`unauthorized`](/docs/errors.md#unauthorized), because it is unclear which one you meant.

## Server-to-server calls

Send the secret key in the `Authorization` header. Everything else is the same as without authentication, including `POST /v1/map` and the lookup endpoints.

```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby|marker:triangle&neighbors=flag+code&w=468&h=280
```

```bash
curl -H "Authorization: Bearer $WHEREBOX_SECRET_KEY" \
  "https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280" -o czechia.png
```

The same header works for `/v1/areas/search`, `/v1/places/search` and `/v1/areas/{id}`. Images fetched by content hash (`/v1/images/{key}`), `/openapi.json`, `/healthz` and `/playground` are public.

## Signed URLs for `<img>` embeds

A browser cannot keep a secret, so an `<img src>` cannot carry an `sk_` key. Use a publishable key and sign the URL on your server instead:

```html
<img src="https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280&apikey=pk_8fK2aQ1mZr9TbL0x4WcYvN3sHd7uJe6p&sig=Jx3w0b2kQnVt9yRmZsLc4u8hAe1pDfGo" alt="Czechia">
```

### Why signatures are needed

Anything in a web page is public. Without a signature, anyone could read your `pk_` key from the page source and use it to render any map they like, and every render would count against your quota. With a signature, the `pk_` key alone is useless. The signature is an HMAC over the whole request: the path and every parameter. It can only be produced by whoever holds the signing secret, and changing even one character of the URL invalidates it. A leaked URL shows the same map it always showed. It cannot be turned into a different area, a larger image or another endpoint (the [`sig` parameter](/docs/reference/http.md#param-sig) covers the path and all other parameters, including `exp`).

The signature does not make a URL secret. Anyone who has the URL can request that exact map until it expires, so use [`exp`](#expiring-urls) and [allowed origins](#allowed-origins) when that matters.

### How to sign

Take the URL without `apikey` and `sig`, then:

1. Add `apikey=<your pk_ key>`, and `exp=<Unix seconds>` if the URL should expire.
2. Build the **canonical request**: the path, `?`, then every parameter except `sig`, sorted by name and then by value (UTF-16 code unit order), each written as `encodeURIComponent(name)=encodeURIComponent(value)` and joined with `&`. Repeated parameters stay repeated.
3. Compute `HMAC-SHA256` of the canonical request, using the signing secret (hex-decoded to bytes) as the key.
4. Encode the digest as base64url without padding, keep the first 32 characters, and add it as `sig`.

Two details matter. First, sign the *decoded* values. In a query string `+` and `%20` both mean a space, and your canonical request must encode a space as `%20` either way. A literal plus sign (`%2B`) stays `%2B`. Second, the order of parameters in the final URL does not matter, but nothing may be added, removed or changed after signing.

The reference lists the parameters you add: [`apikey`](/docs/reference/http.md#param-apikey), [`sig`](/docs/reference/http.md#param-sig) and [`exp`](/docs/reference/http.md#param-exp).

#### Node.js with `@wherebox/accounts`

Inside this repository (or any service that depends on the workspace package), use the helper the server itself uses. It accepts relative and absolute URLs and replaces an existing `sig`.

```js
import { signUrl } from '@wherebox/accounts';

const url = signUrl(
  'https://maps.example.com/v1/map.png?area=Zlínský kraj&w=468&h=280',
  process.env.WHEREBOX_PUBLISHABLE_KEY,
  process.env.WHEREBOX_SIGNING_SECRET,
  { expiresAt: new Date(Date.now() + 3600 * 1000) },
);
```

#### Node.js without dependencies

This is the same algorithm with only `node:crypto`. It is CommonJS; in an ES module use `import { createHmac } from 'node:crypto'` instead of the first line. The test suite runs this exact snippet against the server's verifier.

```js
const { createHmac } = require('node:crypto');

function canonicalRequest(pathname, searchParams) {
  const pairs = [...searchParams.entries()].filter(([name]) => name !== 'sig');
  pairs.sort(([an, av], [bn, bv]) => (an < bn ? -1 : an > bn ? 1 : av < bv ? -1 : av > bv ? 1 : 0));
  const query = pairs.map(([name, value]) => `${encodeURIComponent(name)}=${encodeURIComponent(value)}`).join('&');
  return `${pathname}?${query}`;
}

/** url: absolute URL; signingSecret: the hex signing secret; expiresAt: optional Date. */
function signUrl(url, publishableKey, signingSecret, expiresAt) {
  const u = new URL(url);
  u.searchParams.delete('sig');
  u.searchParams.set('apikey', publishableKey);
  if (expiresAt) u.searchParams.set('exp', String(Math.floor(expiresAt.getTime() / 1000)));
  const sig = createHmac('sha256', Buffer.from(signingSecret, 'hex'))
    .update(canonicalRequest(u.pathname, u.searchParams))
    .digest('base64url')
    .slice(0, 32);
  u.searchParams.set('sig', sig);
  return u.toString();
}
```

#### Python

```python
import base64
import hashlib
import hmac
import time
from urllib.parse import parse_qsl, quote, urlsplit, urlunsplit

# Characters that JavaScript's encodeURIComponent leaves alone.
SAFE = "-_.!~*'()"

def _enc(text):
    return quote(text, safe=SAFE)

def _utf16_key(pair):
    # JavaScript compares strings by UTF-16 code units; Python compares code points.
    return (pair[0].encode("utf-16-be"), pair[1].encode("utf-16-be"))

def canonical_request(path, pairs):
    kept = sorted(((k, v) for k, v in pairs if k != "sig"), key=_utf16_key)
    return path + "?" + "&".join(_enc(k) + "=" + _enc(v) for k, v in kept)

def sign_url(url, publishable_key, signing_secret, expires_at=None):
    """url: absolute URL; signing_secret: the hex signing secret; expires_at: optional Unix seconds."""
    parts = urlsplit(url)
    pairs = [(k, v) for k, v in parse_qsl(parts.query, keep_blank_values=True) if k not in ("sig", "apikey")]
    pairs.append(("apikey", publishable_key))
    if expires_at is not None:
        pairs = [(k, v) for k, v in pairs if k != "exp"] + [("exp", str(int(expires_at)))]
    digest = hmac.new(
        bytes.fromhex(signing_secret),
        canonical_request(parts.path, pairs).encode("utf-8"),
        hashlib.sha256,
    ).digest()
    sig = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")[:32]
    pairs.append(("sig", sig))
    query = "&".join(_enc(k) + "=" + _enc(v) for k, v in pairs)
    return urlunsplit((parts.scheme, parts.netloc, parts.path, query, ""))

# signed = sign_url("https://maps.example.com/v1/map.png?area=Czechia&w=468&h=280",
#                   PUBLISHABLE_KEY, SIGNING_SECRET, expires_at=time.time() + 3600)
```

#### Checking a signed URL

Signed URLs look like this. The signature below is only an illustration, so this request does not authenticate against a real server, but it parses like any other:

```http
GET /v1/map.png?area=Czechia&poi=place:Chřiby&w=468&h=280&apikey=pk_8fK2aQ1mZr9TbL0x4WcYvN3sHd7uJe6p&sig=Jx3w0b2kQnVt9yRmZsLc4u8hAe1pDfGo
```

Names with spaces and accents sign the same way. This one also expires:

```http
GET /v1/map.png?area=Zlínský+kraj&w=468&h=280&apikey=pk_8fK2aQ1mZr9TbL0x4WcYvN3sHd7uJe6p&exp=1798761600&sig=Jx3w0b2kQnVt9yRmZsLc4u8hAe1pDfGo
```

If a signed URL is refused with `invalid URL signature`, the usual causes are a changed parameter, a different path (`.png` vs `.jpg` is a different path), signing with the wrong key's signing secret, or an encoding mismatch (`+` signed as `+` instead of `%20`).

## Expiring URLs

Add `exp` (Unix seconds) before signing and the URL stops working at that moment, with a [`forbidden`](/docs/errors.md#forbidden) response (`signed URL has expired`). Because `exp` is part of the signature, nobody can extend it by editing the URL; you must sign a new one.

Use short lifetimes for maps in emails or one-off pages. For maps that stay on a page for a long time, remember that a new `exp` makes a new URL, so the browser cannot reuse its cached copy of the old one. Rounding `exp` to the next hour or day keeps the URL stable for long enough to be cached.

## Allowed origins

A publishable key can be limited to the sites that may embed it. The server compares the host of the `Origin` header (or the `Referer` header when there is no `Origin`) with the key's list. A pattern is either an exact host (`example.com`) or a subdomain wildcard (`*.example.com`, which matches `www.example.com` and `a.b.example.com` but not `example.com` itself). Look-alike hosts such as `evil-example.com` do not match.

Keep two things in mind. Browsers send these headers on their own, but a script or a `curl` command can send anything, so origins limit casual reuse on other websites and are not a replacement for signatures or expiry. And when a key has allowed origins, a request without either header is refused. If your page uses `referrerpolicy="no-referrer"` or a strict `Referrer-Policy`, the `<img>` request has no `Referer` and the map is refused; use `strict-origin-when-cross-origin` (the browser default) or leave the key without origins.

## Rate limits and quotas

Each account has a **rate limit**, in requests per minute, set by its plan and shared by all of its keys and connected apps. You can burst up to one minute's worth of requests; the allowance refills continuously. Every authenticated response carries the state:

- [`X-RateLimit-Limit`](/docs/reference/http.md#header-x-ratelimit-limit): requests per minute for the account
- [`X-RateLimit-Remaining`](/docs/reference/http.md#header-x-ratelimit-remaining): requests left right now
- [`X-RateLimit-Reset`](/docs/reference/http.md#header-x-ratelimit-reset): when the allowance is full again (Unix seconds)

When it runs out you get `429` [`rate-limited`](/docs/errors.md#rate-limited) with a [`Retry-After`](/docs/reference/http.md#header-retry-after) header (seconds). Wait that long. Limits are per account, so creating more keys does not raise them.

The **quota** is the number of fresh renders per calendar month (UTC) for the whole account. A plan can also have no quota at all. **Cache hits are free**: a request whose image is already in the cache does not count, and it still works after the quota is used up. The same goes for a request that arrives while an identical render is already running. A response that rendered a new image carries [`X-Quota-Remaining`](/docs/reference/http.md#header-x-quota-remaining), the renders left this month. A cache hit has no such header because nothing was rendered. Renders with a road route (`car`, `bike`, `foot`) can also have their own, smaller monthly allowance. When the quota is used up, a request that would need a new render gets `429` [`quota-exceeded`](/docs/errors.md#quota-exceeded), while images that are already cached keep working. To make good use of the quota, keep your URLs stable (see [caching](/docs/guides/errors-and-limits.md#caching-and-etags)), so repeated views of a map are hits.

Rate limits apply to every request, including lookups. Only map renders are metered against the quota.

Plans also limit the output size and whether server-side routing is included. A request over the plan's pixel limit, or one that asks for a routed `profile` (`car`, `bike`, `foot`) on a plan without routing, is refused with `403` [`plan-limit`](/docs/errors.md#plan-limit) before any rendering starts. Straight and geodesic lines are always allowed. The plan definitions are operator settings (`WHEREBOX_PLANS` in the [configuration reference](/docs/reference/configuration.md)). On the hosted service, which is free, the current numbers are on the [fair use](/docs/guides/fair-use.md) page.

## Errors

All failures are `application/problem+json`, as described in [Errors and limits](/docs/guides/errors-and-limits.md). Branch on `type`:

- `401` [`unauthorized`](/docs/errors.md#unauthorized): no key where one is needed (outside the demo allowance), a malformed or unknown key, or both a header and `apikey`. Fix the request.
- `403` [`forbidden`](/docs/errors.md#forbidden): the key or account is revoked or disabled, the signature is missing, wrong or expired, the origin is not allowed, a secret key was used in a URL, or a publishable key was used in the header. Retrying the same request will not help.
- `403` [`plan-limit`](/docs/errors.md#plan-limit): the image is larger than the plan allows, or routing is not included.
- `429` [`rate-limited`](/docs/errors.md#rate-limited): wait for `Retry-After`.
- `429` [`quota-exceeded`](/docs/errors.md#quota-exceeded): the monthly quota is used up, or, without a key, today's demo allowance. Cached maps still work.

```js
const res = await fetch(url, { headers: { authorization: `Bearer ${process.env.WHEREBOX_SECRET_KEY}` } });
if (res.status === 429) {
  const code = (await res.json()).type.split('#')[1];      // "rate-limited" or "quota-exceeded"
  if (code === 'rate-limited') {
    await new Promise((r) => setTimeout(r, Number(res.headers.get('retry-after') ?? 1) * 1000));
    // then retry
  }
}
```

## Admin commands

Operators manage accounts and keys with the CLI. It needs `DATABASE_URL` and the same `WHEREBOX_AUTH_SECRET` as the API (changing that secret invalidates every key and signing secret). Add `--json` to print JSON.

```bash
pnpm mm account create --email dev@example.com --name "Acme"
pnpm mm account list
pnpm mm account plan <acc_id> plus        # e.g. a higher allowance granted on request
pnpm mm account disable <acc_id>          # and: enable

pnpm mm key create <acc_id> --kind secret --name backend
pnpm mm key create <acc_id> --kind publishable --name website --origin example.com --origin "*.example.com"
pnpm mm key list <acc_id>
pnpm mm key revoke <key_id>

pnpm mm usage <acc_id> --from 2026-11-01 --to 2026-11-30
pnpm mm sign "https://maps.example.com/v1/map.png?area=Czechia" --key pk_… --secret <signing secret> --expires 3600
```

Creating a key prints the token once, and for a publishable key also its signing secret. Only a peppered hash of the token is stored, so a lost token cannot be recovered; create a new key and revoke the old one. Plan changes, `disable` and `revoke` reach a running API within about a minute, because it caches key lookups. `usage` lists renders, cache hits and bytes per key and day. `sign` is handy for checking your own signing code: the URL it prints must be identical in meaning to the one your code produces.
