Signed Tile Tokens
Why
An API key embedded in a mobile app can always be extracted from the binary
or sniffed from traffic, and it never expires. Signed tile tokens solve
this: your backend holds a signing secret and mints short-lived tokens
for your app; your app fetches tiles with ?token= instead of ?api_key=.
- A leaked token expires on its own (recommended lifetime: 24 h, hard maximum: 7 days).
- The signing secret never leaves your servers, and you can revoke it from the dashboard at any time; every outstanding token dies instantly.
- Token traffic draws from the same tile quota and rate limits as your plan; nothing new to think about for billing.
Your app ──(your existing auth)──► Your backend
◄──── signed token (JWT, exp ≤ 7 d) ─────┘
│
└──► https://tiles.geocodeme.net/streets/{z}/{x}/{y}.png?token=eyJ...
└─► geocodeme verifies the signature, expiry & your plan limits
1. Create a signing secret
Dashboard → API Keys → Tile signing secrets → Generate signing secret.
You get two values:
| Value | Example | Role |
|---|---|---|
Key id (kid) | tsk_9f27ab31c04d | Public identifier, goes in the JWT header. |
| Secret | gm_tss_... | HMAC-SHA256 signing key. Shown once: store it in your backend's secret manager, never in an app. |
Up to two secrets can be active at once, so you can rotate with zero downtime: generate the new secret, deploy your backend with it, then revoke the old one. Apps refetch tokens automatically (see step 3), so rotation needs no app release.
2. Mint tokens in your backend
A token is a standard JWT (HS256) with your kid in the header:
| Part | Field | Value |
|---|---|---|
| Header | alg | HS256 (only accepted algorithm) |
| Header | kid | Your key id (tsk_...), required |
| Claims | scope | "tiles", required |
| Claims | exp | Unix expiry, required, at most 7 days ahead |
| Claims | iat | Issued-at, recommended |
| Claims | sub | Optional opaque reference (e.g. your user id) for your own tracing |
- Python (PyJWT)
- Node (jsonwebtoken)
- Dart (dart_jsonwebtoken)
import os
import time
import jwt # pip install pyjwt
TILE_KID = os.environ["GEOCODEME_TILE_KID"] # tsk_... (public id)
TILE_SECRET = os.environ["GEOCODEME_TILE_SIGNING_SECRET"] # gm_tss_...
def mint_tile_token(lifetime_seconds: int = 86_400) -> str:
now = int(time.time())
return jwt.encode(
{"scope": "tiles", "iat": now, "exp": now + lifetime_seconds},
TILE_SECRET,
algorithm="HS256",
headers={"kid": TILE_KID},
)
import jwt from 'jsonwebtoken'; // npm install jsonwebtoken
const TILE_KID = process.env.GEOCODEME_TILE_KID; // tsk_... (public id)
const TILE_SECRET = process.env.GEOCODEME_TILE_SIGNING_SECRET; // gm_tss_...
export function mintTileToken(lifetimeSeconds = 86_400) {
return jwt.sign({ scope: 'tiles' }, TILE_SECRET, {
algorithm: 'HS256',
expiresIn: lifetimeSeconds,
keyid: TILE_KID,
});
}
import 'dart:io';
import 'package:dart_jsonwebtoken/dart_jsonwebtoken.dart';
final tileKid = Platform.environment['GEOCODEME_TILE_KID']!; // tsk_... (public id)
final tileSecret = Platform.environment['GEOCODEME_TILE_SIGNING_SECRET']!; // gm_tss_...
String mintTileToken({Duration lifetime = const Duration(hours: 24)}) {
final jwt = JWT({'scope': 'tiles'}, header: {'kid': tileKid});
return jwt.sign(SecretKey(tileSecret),
algorithm: JWTAlgorithm.HS256, expiresIn: lifetime);
}
Expose the token through an authenticated endpoint of your API, e.g.:
GET /map/tile-token → { "token": "eyJ...", "expires_at": 1789300000 }
The tile CDN caches by full URL, so a different token per user fragments the edge cache. Mint one shared token per day (memoize it server-side and hand the same token to every authenticated user); you keep the short-lived security model and a hot CDN cache.
3. Use tokens in your app
Fetch a token at map start, and refetch when a tile request returns 401
(expired token):
- Flutter (flutter_map)
- Leaflet
- MapLibre GL JS
// 1. Get a token from YOUR backend (authenticated call)
final tileToken = await api.getTileToken();
// 2. Use it in the tile URL
TileLayer(
urlTemplate:
'https://tiles.geocodeme.net/streets/{z}/{x}/{y}{r}.png?token=$tileToken',
retinaMode: RetinaMode.isHighDensity(context),
userAgentPackageName: 'com.example.app',
),
RichAttributionWidget(
attributions: [TextSourceAttribution('© OpenMapTiles © OpenStreetMap contributors')],
),
// 3. On 401 (token expired): refetch the token and rebuild the TileLayer
// (e.g. keep the token in a state notifier the TileLayer depends on).
const { token } = await fetch('/map/tile-token').then(r => r.json());
L.tileLayer(
`https://tiles.geocodeme.net/streets/{z}/{x}/{y}.png?token=${token}`,
{ attribution: '© OpenMapTiles © OpenStreetMap contributors', maxZoom: 20 },
).addTo(map);
const { token } = await fetch('/map/tile-token').then(r => r.json());
map.addSource('geocodeme', {
type: 'raster',
tiles: [`https://tiles.geocodeme.net/streets/{z}/{x}/{y}.png?token=${token}`],
tileSize: 256,
attribution: '© OpenMapTiles © OpenStreetMap contributors',
});
Errors
| Status | Cause | What to do |
|---|---|---|
401 | Missing/expired token, bad signature, wrong scope, unknown or revoked kid, exp more than 7 days ahead | Refetch a fresh token from your backend. If it persists, check the secret/kid pair. |
429 | Tile rate limit or monthly tile quota of your plan exceeded | Back off; see Map Tiles for plan limits. |
Security model, honestly
Tokens are bearer credentials: someone extracting the current token from their own device can fetch tiles with it until it expires. What the design guarantees is that the damage is bounded (by the token lifetime, by your plan's tile rate/quota, and by per-IP limits at our edge), and that recovery is instant: revoke the secret in the dashboard and every token signed with it stops working immediately. This is the same model used by the temporary tokens of major map providers, without giving up self-served rotation.