One-tap Strava export over OAuth 2.0
"Send to Strava" is one button. Behind it is a small protocol that trips up more iOS engineers than it should — not because OAuth is hard, but because almost every part of it is about handling tokens without doing something careless. Here's the whole flow as it ships in iRunning.
A runner finishes a 10K, taps a button, and a few seconds later the activity is on their Strava feed. That's the feature. The work underneath is the OAuth 2.0 authorization-code flow with PKCE: bounce the user to Strava's consent screen, get a short-lived authorization code back, trade it for tokens, and then keep those tokens alive responsibly for months without ever asking the user to log in again. Get any of the four steps slightly wrong and you either leak credentials or annoy the user into never tapping the button again.
Why not just store a password?
You can't, and you wouldn't want to. Strava — like every serious API — doesn't let a third-party app collect the user's password. Instead the user authenticates on Strava's own page, and your app receives a token that says "this person granted iRunning permission to write activities, and nothing else." The token is scoped, it expires, and the user can revoke it from Strava's settings without changing their password. That separation is the entire point of OAuth: your app never holds the keys to the account, only a narrow, revocable lease.
The native-app version of this dance adds one wrinkle. A web server can keep a client_secret truly secret; an iOS app cannot — anything bundled in your binary can be extracted. PKCE (Proof Key for Code Exchange) exists precisely to close that gap, and it's non-negotiable for a public client like a mobile app.
PKCE in two sentences
Before sending the user off to consent, generate a random high-entropy string — the code verifier — and keep it in memory. Send only its SHA-256 hash — the code challenge — on the authorization request; later, when you exchange the code for tokens, present the original verifier. The server hashes what you send and checks it matches the challenge it saw earlier, which proves the app redeeming the code is the same app that started the flow. An attacker who intercepts the authorization code on the callback can't use it, because they never had the verifier.
import CryptoKit
import Foundation
enum PKCE {
/// 32 random bytes, base64url-encoded — the secret we keep in memory.
static func makeVerifier() -> String {
var bytes = [UInt8](repeating: 0, count: 32)
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
return Data(bytes).base64URLEncodedString()
}
/// SHA-256 of the verifier, base64url-encoded — the value we send.
static func challenge(for verifier: String) -> String {
let digest = SHA256.hash(data: Data(verifier.utf8))
return Data(digest).base64URLEncodedString()
}
}
extension Data {
/// base64url = base64 with +/ swapped for -_ and no padding.
func base64URLEncodedString() -> String {
base64EncodedString()
.replacingOccurrences(of: "+", with: "-")
.replacingOccurrences(of: "/", with: "_")
.replacingOccurrences(of: "=", with: "")
}
}
The consent screen: ASWebAuthenticationSession, not a webview
This is the single most common mistake I see. People drop a WKWebView into a modal, point it at the authorization URL, and watch for the redirect. Don't. An in-app webview means your process can read everything the user types — the whole trust model collapses, Strava's login may refuse to render, and Apple's reviewers will reject it. The correct tool is ASWebAuthenticationSession from AuthenticationServices. It opens the consent page in a system-managed browser context that shares Safari's cookies (so a logged-in user often skips straight to the consent button), and it hands you the callback URL when the flow completes — without your app ever touching the page.
The callback comes back to a custom URL scheme you register in your Info.plist — say irunning://strava-callback. You declare that scheme as the callbackURLScheme, and the session intercepts the redirect to it automatically.
import AuthenticationServices
@MainActor
final class StravaAuth: NSObject, ASWebAuthenticationPresentationContextProviding {
private let clientID = "YOUR_CLIENT_ID"
private let callbackScheme = "irunning"
private var session: ASWebAuthenticationSession?
/// Returns an authorization code, or throws if the user cancels.
func authorize() async throws -> (code: String, verifier: String) {
let verifier = PKCE.makeVerifier()
let challenge = PKCE.challenge(for: verifier)
var components = URLComponents(string: "https://www.strava.com/oauth/mobile/authorize")!
components.queryItems = [
.init(name: "client_id", value: clientID),
.init(name: "redirect_uri", value: "\(callbackScheme)://strava-callback"),
.init(name: "response_type", value: "code"),
.init(name: "approval_prompt", value: "auto"),
.init(name: "scope", value: "activity:write,read"),
.init(name: "code_challenge", value: challenge),
.init(name: "code_challenge_method", value: "S256"),
]
let callbackURL: URL = try await withCheckedThrowingContinuation { continuation in
let session = ASWebAuthenticationSession(
url: components.url!,
callbackURLScheme: callbackScheme
) { url, error in
if let url { continuation.resume(returning: url) }
else { continuation.resume(throwing: error ?? AuthError.cancelled) }
}
session.presentationContextProvider = self
session.prefersEphemeralWebBrowserSession = false // reuse Safari login
session.start()
self.session = session
}
// Pull ?code=… off the callback. Bail if Strava sent ?error=access_denied.
guard let code = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false)?
.queryItems?.first(where: { $0.name == "code" })?.value
else { throw AuthError.noCode }
return (code, verifier)
}
func presentationAnchor(for _: ASWebAuthenticationSession) -> ASPresentationAnchor {
ASPresentationAnchor() // return your active window in real code
}
}
Two details that matter. Keep a strong reference to the session — if it deallocates mid-flow the browser sheet just vanishes. And set prefersEphemeralWebBrowserSession to false so a user already signed into Strava on the device doesn't have to type their password again; flip it to true only if you have a genuine reason to want a clean, cookie-less session every time.
Trading the code for tokens
The authorization code is single-use and lives for only a few minutes. Immediately POST it to the token endpoint along with the PKCE verifier. What comes back is the prize: an access token (good for a few hours), a refresh token (good until revoked), and an expires_at timestamp telling you exactly when the access token dies.
struct TokenResponse: Decodable {
let accessToken: String
let refreshToken: String
let expiresAt: TimeInterval // Unix seconds
enum CodingKeys: String, CodingKey {
case accessToken = "access_token"
case refreshToken = "refresh_token"
case expiresAt = "expires_at"
}
}
func exchange(code: String, verifier: String) async throws -> TokenResponse {
var request = URLRequest(url: URL(string: "https://www.strava.com/oauth/token")!)
request.httpMethod = "POST"
request.setValue("application/x-www-form-urlencoded",
forHTTPHeaderField: "Content-Type")
let body = [
"client_id": clientID,
"code": code,
"grant_type": "authorization_code",
"code_verifier": verifier,
]
request.httpBody = body
.map { "\($0)=\($1.addingPercentEncoding(withAllowedCharacters: .urlQueryValueAllowed)!)" }
.joined(separator: "&")
.data(using: .utf8)
let (data, response) = try await URLSession.shared.data(for: request)
guard (response as? HTTPURLResponse)?.statusCode == 200 else {
throw AuthError.exchangeFailed
}
return try JSONDecoder().decode(TokenResponse.self, from: data)
}
Where the refresh token lives
The refresh token is the long-lived secret — the thing that lets your app act on the account for months. It belongs in the Keychain, never in UserDefaults. UserDefaults is an unencrypted plist; it lands in iTunes/iCloud backups in the clear and is trivially readable on a jailbroken or simply unlocked device. The Keychain is hardware-encrypted, gated by the device passcode, and won't sync to a backup if you pick the right accessibility class. This isn't a nicety — a leaked refresh token is a standing key to someone's account.
Store it with kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly. AfterFirstUnlock lets a background refresh read it without the device being actively unlocked; ThisDeviceOnly keeps it out of backups and off other devices entirely. Avoid kSecAttrAccessibleAlways — it's been deprecated for years for exactly this reason.
The access token, by contrast, is short-lived and replaceable — I keep it in memory and just re-derive it from the refresh token whenever the app cold-starts. There's no value in persisting something that expires in a few hours.
Refreshing silently
Every call to Strava starts the same way: check whether the access token is still alive, and if not, mint a new one from the refresh token before doing anything else. The user never sees this — no browser, no prompt, just a quick background POST. The only difference from the initial exchange is the grant type and that you send the refresh token instead of a code.
One subtlety bites people: Strava rotates the refresh token on some refreshes, returning a new one in the response. If you ignore it and keep using the old one, your next refresh fails and the user gets silently logged out. Always persist whatever refresh token comes back.
func validAccessToken() async throws -> String {
if let token = inMemoryAccess, Date().timeIntervalSince1970 < expiresAt - 60 {
return token // still good (with a 60s safety margin)
}
guard let refresh = Keychain.load("strava.refresh") else {
throw AuthError.notConnected // need the full consent flow again
}
let body = [
"client_id": clientID,
"grant_type": "refresh_token",
"refresh_token": refresh,
]
let fresh = try await postForm("https://www.strava.com/oauth/token", body)
// Strava may hand back a NEW refresh token — persist it or lose access.
Keychain.save(fresh.refreshToken, for: "strava.refresh")
inMemoryAccess = fresh.accessToken
expiresAt = fresh.expiresAt
return fresh.accessToken
}
Uploading the run, then polling
With a valid access token in hand, the export itself is almost mundane. Strava's upload endpoint takes a file — I send a GPX built from the same gated, denoised track I use for the map, though TCX works too if you want to carry heart-rate and per-lap data. The call is a multipart/form-data POST with the file, a data_type of gpx, and a name and description.
The catch: the upload is asynchronous on Strava's side. The initial POST returns an upload id and a status of "Your activity is still being processed," not a finished activity. You then poll GET /uploads/{id} every couple of seconds until the response carries an activity_id (done) or an error (rejected — usually a duplicate of an activity already on the account). Poll with backoff and a sane ceiling; don't hammer it in a tight loop. When activity_id arrives, that's your one-tap success, and you can deep-link the user straight to the activity.
Rate limits and revocation
Two operational realities you have to design for. First, rate limits: Strava enforces both a short-window cap (per 15 minutes) and a daily cap, and it tells you where you stand in the X-RateLimit-Limit and X-RateLimit-Usage response headers. Read them. When you get a 429, stop and back off — retrying immediately just digs the hole deeper and can get your whole app's API access throttled, not just one user's.
Second, revocation: a user can disconnect your app from Strava's website at any moment, and the first you'll hear of it is a 401 on a call you expected to succeed. Treat a 401 after a fresh refresh as "the grant is gone" — clear the Keychain entry, drop the in-memory token, and surface a quiet "reconnect Strava" state rather than retrying into a wall. Failing gracefully here is the difference between a one-time annoyance and a support ticket.
None of these pieces is individually hard. PKCE is thirty lines, ASWebAuthenticationSession does the heavy lifting on consent, and the uploads are ordinary HTTP. What makes an OAuth integration feel solid — or fragile — is almost entirely about the tokens: getting consent through the system browser instead of a webview, putting the refresh token in the Keychain instead of a plist, honoring the rotated token you get back, and treating expiry, rate limits, and revocation as normal states rather than crashes. Handle the tokens responsibly and the one-tap button just works, quietly, for months. That's the whole job.