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.

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.

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.

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