I like riding bikes, and I regularly use Strava to track my activities. Strava has a helpful feature that suggests route maps starting from your current location based on parameters like distance, elevation goals, and activity type.

The main limitation is that this is a premium subscription feature. Since I do not ride frequently enough to justify the monthly cost, I wanted to see how the application handles this restriction.

The subscription gate showing the route feature restriction

When you attempt to use the feature as a free user, the application still generates a preview of the route map. However, the user interface prevents you from interacting with it or viewing the route in detail.

The route preview visible behind the subscription wall

Because the client application renders this preview, the underlying route geometry must be transmitted to the device. This suggested that the backend computes the route data and sends it to the client regardless of the user’s subscription status, relying on client-side UI restrictions to gate access. To verify this, I decided to analyze the API traffic.

Setup and reconnaissance

To inspect the network traffic, I set up mitmproxy on my analysis workstation and configured my iOS test device to route traffic through it.

After launching the application, all API requests failed immediately due to TLS connection failures. Checking the device console logs via oslog revealed the following activity:

debug	17:56:35.003650+0200	Strava	found no value for key feature.ssl-pinning-ios-release.off

The application attempts to check a feature flag named ssl-pinning-ios-release.off on startup. Because this configuration key is missing or false by default, SSL pinning remains active. This causes the TLS handshake to abort when the application detects the custom certificate authority used by the proxy:

debug	17:56:35.057941+0200	Strava	An SSL error has occurred and a secure connection to the server cannot be made.
debug	17:56:35.058116+0200	Strava	The certificate for this server is invalid.
debug	17:56:35.058202+0200	Strava	The certificate for this server was signed by an unknown certifying authority.

Consequently, the network layer blocks further communication:

error	17:56:35.556900+0200	Strava	Problem fetching athlete on launch: Error Domain=NSURLErrorDomain Code=-999
error	17:56:35.759613+0200	Strava	There was a problem fetching feature switches, error: Error Domain=NSURLErrorDomain Code=-999

Standard NSURLError codes (ranging from -1200 through -1206) are returned, indicating that the client rejected the TLS certificate chain.

Disabling SSL pinning

To bypass the certificate validation checks, I used a jailbroken test environment with SSL Kill Switch 3. This tool dynamically hooks low-level security framework functions on iOS, such as those within Security.framework (SecTrustEvaluateWithError) and high-level libraries like TrustKit or AFNetworking, forcing them to accept any presented certificate chain.

After restarting the in-app network services, the connection established successfully, and the HTTP requests began appearing in the proxy interface.

Analyzing the GraphQL API

With traffic inspection active, I triggered a route search in the iOS app. Strava uses the Apollo GraphQL client for data exchange. I captured the following request payload directed to their GraphQL gateway:

curl 'https://graphql.strava.com/' \
  -X POST \
  -H 'apollographql-client-name: strava-ios' \
  -H 'apollographql-client-version: 422.0.1-49113' \
  -H 'User-Agent: Strava 422.0.1 (49113)|iPhone|iPhone10,1|iOS|16.7.15|en-FR' \
  -H 'Authorization: Bearer [REDACTED]' \
  -H 'Content-Type: application/json' \
  --data-raw '{"operationName":"SuggestedRoutes","query":"......","variables":{.....}}'

The server response to the suggestedRoutesBySourceGeo query contains the complete geospatial data required to reconstruct the maps:

{
  "data": {
    "suggestedRoutesBySourceGeo": {
      "routes": {
        "nodes": [
          {
            "elevationGain": 97.66,
            "length": 6134.7,
            "locationSummary": "Orsay, Essonne, France",
            "title": "Rue de Bellevue-Avenue d'Alsace-Rue André Ampère",
            "routeType": "Run",
            "routeDetails": { "overallDifficulty": "Easy" },
            "themedMapImages": [
              {
                "darkUrl": "https://d3o5xota0a1fcr.cloudfront.net/v6/maps/ZPBCWQPFGM...",
                "lightUrl": "https://d3o5xota0a1fcr.cloudfront.net/v6/maps/BFLM3G3B5D..."
              }
            ],
            "legs": [
              { "paths": [{ "polyline": { "data": "qhdhHsqiLl@e@t@c@..." } }] },
              { "paths": [{ "polyline": { "data": "}|chHyoiLCjB..." } }] }
            ]
          }
        ]
      },
      "totalCount": 24,
      "pointSourceType": {
        "currentLocation": { "point": { "lng": 2.185, "lat": 48.688 } }
      }
    }
  }
}

The payload structure includes:

  • legs[].paths[].polyline.data: Geolocation data points encoded using the Google Encoded Polyline Algorithm Format.
  • themedMapImages: Pre-rendered map tiles hosted on CloudFront.
  • routePolylineData: References to images and metadata associated with key points along the route.
  • Descriptive statistics such as distance, cumulative elevation gain, difficulty levels, and predicted duration.

Python implementation

I wrote a command-line script to automate querying this endpoint and parsing the returned coordinates. The core is the real GraphQL query reconstructed from the intercepted Apollo network call:

from dataclasses import dataclass, asdict
from typing import Any, Optional
import requests

GRAPHQL_URL = "https://graphql.strava.com/"

SUGGESTED_ROUTES_QUERY = """query SuggestedRoutes(
  $args: SuggestedRouteOptionsInput!,
  $first: Int,
  $resolutions: [FlatmapResolutionInput!]!,
  $minSizeDesired: Short!,
  $lookupOptions: LookupOptionsInput
) {
  suggestedRoutesBySourceGeo(args: $args, first: $first) {
    routes { nodes {
      elevationGain
      length
      locationSummary(lookupOptions: $lookupOptions)
      title
      routeType
      routeUrl
      routeDetails { overallDifficulty }
      themedMapImages(resolutions: $resolutions) { darkUrl lightUrl }
      legs { paths { polyline { data } } }
      completionTimeEstimation { expectedTime }
    } }
    totalCount
    pointSourceType {
      currentLocation { point { lat lng } }
    }
  }
}"""


def fetch_routes(token: str, lat: float, lng: float) -> dict:
    offset = 0.05  # 5.5km bounding box
    variables = {
        "args": {
            "prefs": {
                "difficulty": ["Easy"],
                "elevation": 0,
                "routeTypes": ["Run"],
                "surfaceType": "Unknown",
                "targetDistance": -1,
            },
            "source": {
                "boundingBoxWithPoint": {
                    "boundingBox": {
                        "northeastCorner": {"lat": lat + offset, "lng": lng + offset},
                        "northwestCorner": {"lat": lat + offset, "lng": lng - offset},
                        "southeastCorner": {"lat": lat - offset, "lng": lng + offset},
                        "southwestCorner": {"lat": lat - offset, "lng": lng - offset},
                    },
                    "point": {
                        "currentLocation": {"point": {"lat": lat, "lng": lng}}
                    },
                }
            },
        },
        "first": 15,
        "resolutions": [{"height": 512, "width": 512}],
        "minSizeDesired": 512,
        "lookupOptions": {"locale": "en", "source": "Mysql"},
    }

    headers = {
        "Authorization": f"Bearer {token}",
        "apollographql-client-name": "strava-ios",
        "apollographql-client-version": "422.0.1-49113",
        "User-Agent": "Strava 422.0.1 (49113)|iPhone|iPhone10,1|iOS|16.7.15|en-FR",
        "Content-Type": "application/json",
    }

    resp = requests.post(
        GRAPHQL_URL,
        headers=headers,
        json={"operationName": "SuggestedRoutes",
              "query": SUGGESTED_ROUTES_QUERY,
              "variables": variables},
    )
    return resp.json()["data"]["suggestedRoutesBySourceGeo"]

Authentication uses the same OTP flow the iOS app uses, POST your email to cdn-1.strava.com/api/v3/oauth/request_otp, then exchange the OTP code for a bearer token:

import requests

CLIENT_SECRET = "0012dc03a59bfd0340b1c75763e6e880985816a3"

headers = {
    "User-Agent": "Strava 422.0.1 (49113)|iPhone|iPhone10,1|iOS|16.7.15|en-FR",
    "x-strava-nav-version": "2",
    "Content-Type": "application/json",
}

# Step 1 — request OTP
resp = requests.post(
    "https://cdn-1.strava.com/api/v3/oauth/request_otp",
    headers=headers, params=[("hl", "en")],
    json={"email": "[email protected]", "client_id": "1", "logging_in": True},
)
otp_state = resp.json()["otp_state"]

# Step 2 — exchange OTP for token
resp = requests.post(
    "https://cdn-1.strava.com/api/v3/oauth/login/otp",
    headers=headers, params=[("hl", "en")],
    json={"email": "[email protected]", "otp_state": otp_state,
          "client_id": "1", "otp": "123456",
          "client_secret": CLIENT_SECRET},
)
token = resp.json()["access_token"]

With the token and the GraphQL response, I decode the route polylines and render them with Leaflet.js:

from polyline import decode as decode_polyline

for route in data["routes"]["nodes"]:
    print(f"{route['title']}")
    print(f"  {route['routeType']} | {route['length']/1000:.1f} km | {route['elevationGain']:.0f} m")
    print(f"  {route['locationSummary']}")

    for leg in route["legs"]:
        for path in leg["paths"]:
            coords = decode_polyline(path["polyline"]["data"])
            # coords is a list of (lat, lng) tuples — feed directly into Leaflet

To run the full implementation with all features (map rendering, bounding box, filters), https://github.com/NohamR/strava-route-map/tree/main/python:

uv run main.py \
  --location 48.0,2.0 \
  --route-type Run \
  --difficulty Easy \
  --target-distance 6

Web interface and tools

To make coordinate selection simpler than manually inputting latitude and longitude, I created a utility page (cli_generator.html) integrating a Leaflet map. Clicking on the map retrieves the coordinate values and formats the shell command.

Additionally, I ported this workflow into a single-page web application using React, TypeScript, Vite, Tailwind CSS, and shadcn/ui components.

Handling CORS (Cross-Origin Resource Sharing)

Because the client-side application runs in a standard browser environment, direct requests to graphql.strava.com fail due to CORS restrictions. To resolve this, I routed the requests through a lightweight proxy deployed as a Cloudflare Worker, which adds the appropriate Access-Control-Allow-Origin headers to the responses.

Exporting routes

The web interface parses the polylines and offers an export option to save the paths as standard GPX 1.1 files. These files can be loaded into various offline navigation applications, such as GPX Viewer 2, for real-time tracking during rides.

The exported path loaded into a GPX viewer application

Conclusion

The web interface is hosted and accessible at nohamr.github.io/strava-route-map and the source code is available on GitHub.