Secure Frontend Token Exchange
Learn how to securely use Geog API tokens in frontend applications like MapLibre GL JS, Leaflet, and React without exposing long-lived credentials.
Overview
The token exchange pattern keeps your long-lived API token secure on your backend while providing short-lived tokens to your frontend:
- Store long-lived API token securely on your backend
- Create a backend endpoint that exchanges for short-lived tokens
- Your frontend fetches short-lived tokens from your backend
- Use short-lived tokens in mapping libraries
Short-lived tokens expire after 1-4 hours, limiting exposure if compromised.
Backend Implementation
Node.js / Express
import express from "express";
const app = express();
const GEOG_API_KEY = process.env.GEOG_API_KEY; // Long-lived token (keep secret!)
app.get("/api/map-token", async (req, res) => {
try {
const response = await fetch("https://api.geog.dev/v1/auth/token", {
method: "POST",
headers: {
Authorization: `Bearer ${GEOG_API_KEY}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
ttl: 7200, // 2 hours
scope: "tiles:read", // Only what frontend needs
}),
});
if (!response.ok) {
throw new Error(`Token exchange failed: ${response.statusText}`);
}
const data = await response.json();
res.json(data);
} catch (error) {
console.error("Token exchange error:", error);
res.status(500).json({ error: "Failed to get map token" });
}
});
app.listen(3000);
Python / Flask
from flask import Flask, jsonify
import requests
import os
app = Flask(__name__)
GEOG_API_KEY = os.environ['GEOG_API_KEY'] # Long-lived token (keep secret!)
@app.route('/api/map-token')
def get_map_token():
try:
response = requests.post(
'https://api.geog.dev/v1/auth/token',
headers={
'Authorization': f'Bearer {GEOG_API_KEY}',
'Content-Type': 'application/json'
},
json={
'ttl': 7200, # 2 hours
'scope': 'tiles:read'
}
)
response.raise_for_status()
return jsonify(response.json())
except requests.RequestException as e:
print(f'Token exchange error: {e}')
return jsonify({'error': 'Failed to get map token'}), 500
if __name__ == '__main__':
app.run(port=3000)
Frontend Implementation
MapLibre GL JS
// Token manager with automatic refresh
class GeogTokenManager {
constructor() {
this.token = null;
this.expiresAt = null;
}
async getToken() {
// Refresh if token is expired or will expire in next 5 minutes
if (
!this.token ||
!this.expiresAt ||
new Date(this.expiresAt).getTime() - Date.now() < 300000
) {
await this.refresh();
}
return this.token;
}
async refresh() {
const response = await fetch("/api/map-token");
const data = await response.json();
this.token = data.access_token;
this.expiresAt = data.expires_at;
// Schedule refresh 5 minutes before expiration
const refreshTime =
new Date(this.expiresAt).getTime() - Date.now() - 300000;
if (refreshTime > 0) {
setTimeout(() => this.refresh(), refreshTime);
}
}
}
// Initialize token manager
const tokenManager = new GeogTokenManager();
// Create map with transformRequest for authentication
const map = new maplibregl.Map({
container: "map",
style: {
version: 8,
sources: {
geog: {
type: "vector",
tiles: [
"https://api.geog.dev/v1/tiles/twin-cities/{z}/{x}/{y}.mvt",
],
},
},
layers: [
{
id: "buildings",
type: "fill",
source: "geog",
"source-layer": "buildings",
paint: {
"fill-color": "#088",
"fill-opacity": 0.8,
},
},
],
},
center: [-93.265, 44.9778],
zoom: 12,
transformRequest: async (url, resourceType) => {
// Add authentication header to tile requests
if (resourceType === "Tile" && url.startsWith("https://api.geog.dev")) {
const token = await tokenManager.getToken();
return {
url: url,
headers: {
Authorization: `Bearer ${token}`,
},
};
}
return { url };
},
});
Leaflet
// Token manager (same as MapLibre example)
class GeogTokenManager {
constructor() {
this.token = null;
this.expiresAt = null;
}
async getToken() {
if (
!this.token ||
!this.expiresAt ||
new Date(this.expiresAt).getTime() - Date.now() < 300000
) {
await this.refresh();
}
return this.token;
}
async refresh() {
const response = await fetch("/api/map-token");
const data = await response.json();
this.token = data.access_token;
this.expiresAt = data.expires_at;
const refreshTime =
new Date(this.expiresAt).getTime() - Date.now() - 300000;
if (refreshTime > 0) {
setTimeout(() => this.refresh(), refreshTime);
}
}
}
const tokenManager = new GeogTokenManager();
// Leaflet requires URL modification since it doesn't support transformRequest
// Use a tile URL template with token parameter, and update it on refresh
let currentToken = null;
async function initMap() {
currentToken = await tokenManager.getToken();
const map = L.map("map").setView([44.9778, -93.265], 12);
// Custom tile layer with authentication
const geogLayer = L.tileLayer(
"https://api.geog.dev/v1/tiles/twin-cities/{z}/{x}/{y}.mvt",
{
attribution: "© Geog",
beforeFetch: (request) => {
// Add authorization header
request.headers["Authorization"] = `Bearer ${currentToken}`;
return request;
},
},
);
geogLayer.addTo(map);
// Note: Leaflet doesn't natively support vector tiles
// Consider using Leaflet.VectorGrid plugin:
// https://github.com/Leaflet/Leaflet.VectorGrid
}
initMap();
React with MapLibre
import { useEffect, useState, useRef } from 'react';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
class TokenManager {
private token: string | null = null;
private expiresAt: string | null = null;
async getToken(): Promise<string> {
if (!this.token || !this.expiresAt ||
new Date(this.expiresAt).getTime() - Date.now() < 300000) {
await this.refresh();
}
return this.token!;
}
private async refresh(): Promise<void> {
const response = await fetch('/api/map-token');
const data = await response.json();
this.token = data.access_token;
this.expiresAt = data.expires_at;
const refreshTime = new Date(this.expiresAt).getTime() - Date.now() - 300000;
if (refreshTime > 0) {
setTimeout(() => this.refresh(), refreshTime);
}
}
}
const tokenManager = new TokenManager();
export function Map() {
const mapContainer = useRef<HTMLDivElement>(null);
const map = useRef<maplibregl.Map | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!mapContainer.current || map.current) return;
map.current = new maplibregl.Map({
container: mapContainer.current,
style: {
version: 8,
sources: {
geog: {
type: 'vector',
tiles: ['https://api.geog.dev/v1/tiles/twin-cities/{z}/{x}/{y}.mvt']
}
},
layers: [
{
id: 'buildings',
type: 'fill',
source: 'geog',
'source-layer': 'buildings',
paint: {
'fill-color': '#088',
'fill-opacity': 0.8
}
}
]
},
center: [-93.2650, 44.9778],
zoom: 12,
transformRequest: async (url, resourceType) => {
if (resourceType === 'Tile' && url.startsWith('https://api.geog.dev')) {
const token = await tokenManager.getToken();
return {
url,
headers: { 'Authorization': `Bearer ${token}` }
};
}
return { url };
}
});
map.current.on('load', () => {
setLoading(false);
});
return () => {
map.current?.remove();
};
}, []);
return (
<div>
{loading && <div>Loading map...</div>}
<div ref={mapContainer} style={{ width: '100%', height: '500px' }} />
</div>
);
}
Best Practices
Token Lifetime
- Public websites: Use 1-2 hours (3600-7200 seconds)
- Internal apps: Can use up to 4 hours (14400 seconds)
- Consider: Balance between security (shorter is better) and user experience (fewer refreshes)
Token Refresh
- Proactive refresh: Refresh 5 minutes before expiration to avoid service interruption
- Error handling: Implement retry logic for failed token exchanges
- Background refresh: Use setTimeout/setInterval to refresh in background
Scope Restriction
- Principle of least privilege: Only request scopes your frontend actually needs
- Example: If only displaying vector tiles, use
scope: "tiles:read"instead of inheriting all parent scopes
Security Considerations
- Never expose long-lived tokens: Always keep them on your backend
- HTTPS only: Always use HTTPS for both backend and frontend
- CORS configuration: Configure your backend's CORS settings appropriately
- Token storage: Don't store short-lived tokens in localStorage (they refresh anyway)
- Error monitoring: Log token exchange failures for debugging
Troubleshooting
"invalid_scope" Error
Problem: Requested scopes exceed parent token permissions
Solution: Check that your scope parameter only includes scopes available in your long-lived API token. You can verify your token's scopes in the Geog dashboard.
"invalid_ttl" Error
Problem: TTL outside valid range (1-14400 seconds)
Solution: Ensure your ttl parameter is between 1 and 14400. If omitted, defaults to 3600.
Token Exchange Fails but Long-Lived Token Works
Problem: Backend can't exchange token, but direct API calls work
Solution:
- Verify your backend is sending the
Authorizationheader correctly - Check that
Content-Type: application/jsonheader is set - Ensure request body is valid JSON
Map Tiles Not Loading
Problem: Map displays but tiles don't load
Solution:
- Check browser console for 401/403 errors
- Verify token is being added to tile requests (check Network tab)
- Ensure token hasn't expired (check
expires_atfield) - Confirm your transformRequest/beforeFetch implementation is correct
See Also
- Token Exchange API Reference - Endpoint spec and parameters
- Mapping Libraries Guide - Integrating with map libraries
- Authentication Reference - API authentication flows