Stop Hardcoding Maps Platform API Keys!
Tags: googlemaps, jwt, firebase, googlecloud
Published: 2024-06-12 04:15:41

One of the really cool things about the new suite of APIs that Google Maps Platform has been releasing lately ([Routes API](https://developers.google.com/maps/documentation/routes), [Places API](https://developers.google.com/maps/documentation/places/web-service/op-overview), etc) is that they look and feel a lot like other Google Cloud Platform APIs. They're exposed via gRPC, support field masks, and let developers authenticate via OAuth. A really helpful side effects of OAuth is support for JSON Web Tokens (JWT) credentials which allow apps to do a much better job securing client-side applications.

Classic Google Maps APIs rely on a a hardcoded API key which is not great. Hardcoding passwords was cool in 2005 (maybe?) but it's 2024, we can do better.

Using a small snippet of code, our app can generate a unique auth token for each website visitor that will expire after a defined period. Even if the user maliciously "borrowed" that token, it would only be valid until the expiration period you specified.

## JWT Intro
If you're new to JWTs like I am, they are base64 encoded JSON blobs that get signed using a private key. APIs can read contents of that JSON object and verify the signature to authenticate them. Here's what one looks like:

```
eyJhbGciOiJSUzI1NiIsImtpZCI6ImVlODk1OWMzYzFhMDdlMTBlZGJjMDE3NWI2ZmZmN2I1ZGYyOTBiZTIiLCJ0eXAiOiJKV1QifQ.eyJzY29wZSI6Imh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL2F1dGgvZ2VvLXBsYXRmb3JtLnJvdXRlcyIsImlzcyI6InVidW50dS12bUBob2xpZGF5cy0xMTcwLmlhbS5nc2VydmljZWFjY291bnQuY29tIiwic3ViIjoidWJ1bnR1LXZtQGhvbGlkYXlzLTExNzAuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJhdWQiOiJodHRwczovL3JvdXRlcy5nb29nbGVhcGlzLmNvbS8iLCJleHAiOjE2Njc4ODQ0NTIsImlhdCI6MTY2Nzg4NDMzMn0.Zey0GtvSH78_xfBTNL-Ij0qm1dK9wqDc5nllYLPZyWNp_V5sYVKaPpWSjJ2IRVHBdhKBLYgXVKLty7Dlo0BMW9SJ4eexIxmdM8IR3CeH5SmYLl4pQxV3S8eO_5T41B6LCD49gKTtlXIWvtCoGitWDSYiFCZauf2zoIEa5XZ_TkazMr1DGYbc9w8UvtXVARAby2WRbSiHyqkjSsAU5HoKClKhaw7NaP1vNJ-7IlpTz9t-sTSZwl-6wur65gI_FtAGiohWPUILRY-YKMhb_wXQ5AtlDUmvGKdqNzuXBMmk8-iiQTwmYPuWQBNt0MtK7hfghWyWubUjBfT0t4yiGSrHmA
```

If you [decode](https://jwt.io/#id_token=eyJhbGciOiJSUzI1NiIsImtpZCI6ImVlODk1OWMzYzFhMDdlMTBlZGJjMDE3NWI2ZmZmN2I1ZGYyOTBiZTIiLCJ0eXAiOiJKV1QifQ.eyJzY29wZSI6Imh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL2F1dGgvZ2VvLXBsYXRmb3JtLnJvdXRlcyIsImlzcyI6InVidW50dS12bUBob2xpZGF5cy0xMTcwLmlhbS5nc2VydmljZWFjY291bnQuY29tIiwic3ViIjoidWJ1bnR1LXZtQGhvbGlkYXlzLTExNzAuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJhdWQiOiJodHRwczovL3JvdXRlcy5nb29nbGVhcGlzLmNvbS8iLCJleHAiOjE2Njc4ODQ0NTIsImlhdCI6MTY2Nzg4NDMzMn0.Zey0GtvSH78_xfBTNL-Ij0qm1dK9wqDc5nllYLPZyWNp_V5sYVKaPpWSjJ2IRVHBdhKBLYgXVKLty7Dlo0BMW9SJ4eexIxmdM8IR3CeH5SmYLl4pQxV3S8eO_5T41B6LCD49gKTtlXIWvtCoGitWDSYiFCZauf2zoIEa5XZ_TkazMr1DGYbc9w8UvtXVARAby2WRbSiHyqkjSsAU5HoKClKhaw7NaP1vNJ-7IlpTz9t-sTSZwl-6wur65gI_FtAGiohWPUILRY-YKMhb_wXQ5AtlDUmvGKdqNzuXBMmk8-iiQTwmYPuWQBNt0MtK7hfghWyWubUjBfT0t4yiGSrHmA) that token, you can see it contains information about the key it was signed with:

```json
{
  "alg": "RS256",
  "kid": "ee8959c3c1a07e10edbc0175b6fff7b5df290be2",
  "typ": "JWT"
}
```

and, more importantly, payload with information about who issued the token, what it can be used for, and when it will expire:

```json
{
  "scope": "https://www.googleapis.com/auth/geo-platform.routes",
  "aud": "https://routes.googleapis.com/",
  "exp": 1667880959,
  "iat": 1667880839,
  "iss": "ubuntu-vm@holidays-1170.iam.gserviceaccount.com",
  "sub": "ubuntu-vm@holidays-1170.iam.gserviceaccount.com"
}
```

In my experience, most Google Maps Platform APIs expects a token to contain a payload with the following fields:

field | description |
------|-----|
`exp` | Expiration time for the token.       |
`iat` | Issued time for the token (aka now). |
`aud` | API endpoint the token is intended for, like `https://routes.googleapis.com/`
`scope` | Scopes, separated by spaces, this token can be used for, like `https://www.googleapis.com/auth/geo-platform.routes`

_From what I can tell, either a `scope` or `aud`ience field needs to be set. I don't know what's the "right" way._

I've collected a bunch of options for Scope and Audience [here](https://github.com/bamnet/gmp-jwt/blob/main/apis/apis.go).

## Generating a JWT

The biggest downside to JWTs is that you have to generate them on the fly - you can't just hardcode them into your app like you could with good old AIza. Generating a JWT involves building an JSON object with the right fields (see above), signing it, and then base64 encoding it to a string. https://jwt.io/introduction walks through this in glorious detail.

To make that step easier, I created a little Go backend which will generate tokens and sign them using a GCP Service Account: https://github.com/bamnet/gmp-jwt.

Running this backend somewhere like Cloud Run makes it easy to start generating tokens since you get access to a Default Service Account but you can run this anywhere and set [Application Default Credentials](https://cloud.google.com/docs/authentication/application-default-credentials).

### But what protects the JWT generator?

Great question. Even though our JWT tokens are tightly scoped and moderately TTLed, an attacker could just scrape them from our backend that generates them. That would be no fun.

Using [Firebase AppCheck](https://firebase.google.com/docs/app-check) we can verify that the environment requesting a token looks legit before giving them a JWT.

The frontend just needs to include recaptcha v3 (which is totally silent now - no more traffic lights, cross walks, or bicycles) and a bit of Firebase code to wire up the token.

## Sending a JWT to Google Maps Platform

To authenticate using a JWT to a modern Google Maps Platform API, we use Bearer Authentication. Set the `Authorization` header to `Bearer ${token}`.

In JavaScript, that snippet might look like:

```ts
const response = await fetch('https://routes.googleapis.com/directions/v2:computeRoutes', {
  method: 'POST',
  headers: {
      'Authorization': 'Bearer eyJhbGciOiJSUzI1NiIsImtpZCI6ImVlODk1OWMzYzFhMDdlMTBlZGJjMDE3NWI2ZmZmN2I1ZGYyOTBiZTIiLCJ0eXAiOiJKV1QifQ.eyJzY29wZSI6Imh0dHBzOi8vd3d3Lmdvb2dsZWFwaXMuY29tL2F1dGgvZ2VvLXBsYXRmb3JtLnJvdXRlcyIsImlzcyI6InVidW50dS12bUBob2xpZGF5cy0xMTcwLmlhbS5nc2VydmljZWFjY291bnQuY29tIiwic3ViIjoidWJ1bnR1LXZtQGhvbGlkYXlzLTExNzAuaWFtLmdzZXJ2aWNlYWNjb3VudC5jb20iLCJhdWQiOiJodHRwczovL3JvdXRlcy5nb29nbGVhcGlzLmNvbS8iLCJleHAiOjE2Njc4ODQ0NTIsImlhdCI6MTY2Nzg4NDMzMn0.Zey0GtvSH78_xfBTNL-Ij0qm1dK9wqDc5nllYLPZyWNp_V5sYVKaPpWSjJ2IRVHBdhKBLYgXVKLty7Dlo0BMW9SJ4eexIxmdM8IR3CeH5SmYLl4pQxV3S8eO_5T41B6LCD49gKTtlXIWvtCoGitWDSYiFCZauf2zoIEa5XZ_TkazMr1DGYbc9w8UvtXVARAby2WRbSiHyqkjSsAU5HoKClKhaw7NaP1vNJ-7IlpTz9t-sTSZwl-6wur65gI_FtAGiohWPUILRY-YKMhb_wXQ5AtlDUmvGKdqNzuXBMmk8-iiQTwmYPuWQBNt0MtK7hfghWyWubUjBfT0t4yiGSrHmA',
      'Content-Type': 'application/json',
      'X-Goog-FieldMask': 'routes.duration,routes.distanceMeters', 
  },
  body: JSON.stringify({
    // Routes API request body.
  }),
});
```

I wouldn't dream of sharing an API Key in a blogpost but an expired JWT, no problem!

## Putting it all together

### Server-Side

You need to deploy a server endpoint somewhere which will mint JWTs, optionally after checking Firebase App Check. A sample Cloud Run function I use is @ https://github.com/bamnet/gmp-jwt.

### Client-Side 

1. (optional) Get an app check token from Firebase.
2. Requests a JWT from the server endpoint you deployed above, optionally passing that app check token from Step 1.
3. Call the desired Google Maps Platform API passing `Authorization: Bearer ${token}`, passing the token from Step 2.
4. ???
5. Profit.

Here's an example in JS, but you can do the same thing in Android & iOS:

```ts
// Initialize Firebase.
const app = initializeApp(firebaseConfig);

// Initialize AppCheck.
const appCheck = initializeAppCheck(app, {
  provider: new ReCaptchaV3Provider(/** reCAPTCHA Key */ ''),
  isTokenAutoRefreshEnabled: true
});

// Grab an AppCheck token.
const appCheckToken = await getToken(appCheck).then(t => t.token);

// Call our backend to convert the AppCheck token into a JWT.
const jwt = await fetch(/** JWT Minting Backend */ '', {
  headers: {
    'X-Firebase-AppCheck': appCheckToken,
  }
}).then((data) => data.text());

// Call the Routes API.
// Look ma, no hardcoded API key!
const response = await fetch('https://routes.googleapis.com/directions/v2:computeRoutes', {
  method: 'POST',
  headers: {
      'Authorization': `Bearer ${jwt}`, // Pass our JWT!
      'Content-Type': 'application/json',
      'X-Goog-FieldMask': 'routes.duration,routes.distanceMeters', 
  },
  body: JSON.stringify({
    // Routes API request body.
  }),
});

console.log(response);

```

Ta-Da, no more hardcoded API key!

### Not-So-Frequently Asked Questions

**Can a single JWT be used calling multiple APIs?**

Yes. In my tests, a token can have multiple scopes, but only 1 audience. Scopes are separated with spaces.  To generate a token for both Places and Routes, you'd set:

```json
{
  "scope": "https://www.googleapis.com/auth/geo-platform.routes https://www.googleapis.com/auth/maps-platform.places",
  ...
}
```

**Couldn't you proxy all requests through a trusted server which added & removed an API key?**

Yes, but then you have a dependency in the serving path for ~every request to an API. That adds some marginal latency and could have scaling challenges for high-QPS APIs like Map Tiles or Places Autocomplete.  That also means the proxy servers will see all requests which might mean more privacy / compliance work.

**Does this work for the Maps JavaScript API?**

No, but that sounds like a good [feature request](https://issuetracker.google.com/savedsearches/558438).