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:

  1. Store long-lived API token securely on your backend
  2. Create a backend endpoint that exchanges for short-lived tokens
  3. Your frontend fetches short-lived tokens from your backend
  4. 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

  1. Never expose long-lived tokens: Always keep them on your backend
  2. HTTPS only: Always use HTTPS for both backend and frontend
  3. CORS configuration: Configure your backend's CORS settings appropriately
  4. Token storage: Don't store short-lived tokens in localStorage (they refresh anyway)
  5. 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:

  1. Verify your backend is sending the Authorization header correctly
  2. Check that Content-Type: application/json header is set
  3. Ensure request body is valid JSON

Map Tiles Not Loading

Problem: Map displays but tiles don't load

Solution:

  1. Check browser console for 401/403 errors
  2. Verify token is being added to tile requests (check Network tab)
  3. Ensure token hasn't expired (check expires_at field)
  4. Confirm your transformRequest/beforeFetch implementation is correct

See Also