我意外地成为了PureGym的非官方Apple Wallet开发者。
I accidentally became PureGym’s unofficial Apple Wallet developer

原始链接: https://drobinin.com/posts/how-i-accidentally-became-puregyms-unofficial-apple-wallet-developer/

## 47秒健身房入口 & 开发者复仇 这个故事讲述了一位开发者对PureGym令人痛苦且复杂的入口流程的沮丧——持续47秒,一周六天。由于糟糕的用户体验,并且讽刺的是他从事iOS应用优化工作,他踏上了一项绕过系统的任务。 他发现了一个奇怪的安全矛盾:八年前的PIN码仍然可以访问,而应用程序中的二维码每分钟刷新一次。通过使用`mitmproxy`等工具进行逆向工程,他发现了应用程序出人意料的不安全API,揭示了健身房PIN码实际上充当了API密码。 解决方案?构建一个自定义的Apple Wallet通行证,可以静默更新二维码,将入口时间缩短到令人愉悦的3秒——每年节省近4小时。他甚至抓取了健身房位置信息,以便进行便捷的锁屏集成。 虽然承认可能违反了服务条款,但这位开发者强调了一个核心问题:有时外部解决方案源于组织的不作为。他向PureGym提供他的专业知识,俏皮地建议他们已经从他的工作中受益,并最终庆祝了对日常摩擦的小胜利。

## PureGym 的 Apple Wallet “功能” - 黑客视角 一名开发者意外地成为了 PureGym 的非官方 Apple Wallet 实现者,原因是他在他们的应用程序中发现了安全漏洞。该应用程序本质上是其移动网站的封装,添加 Apple Wallet 支持被认为维护成本过高——一个典型的“如果我们构建它,就必须负责它”的情况。 开发者发现该系统出乎意料地容易访问,健身房 PIN 码实际上充当 API 密码。令牌每周通过推送通知刷新(可能影响电池寿命,但设计上是高效的),并且令人惊讶的是,旧令牌仍然有效一周。 讨论强调了对安全性的担忧——特别是 PIN 码泄露的可能性以及 API 访问缺乏速率限制。其他人指出不必要的复杂性,并指出许多健身房成功地使用简单的条形码系统。 这篇文章引发了关于强制使用应用程序的数据隐私影响以及现代系统中安全措施的普遍性的争论,同时也对文章是否由人工智能生成表示怀疑。 最终,开发者的经历展示了对一家受欢迎的健身房应用程序内部运作的引人入胜,但又略显令人不安的一瞥。
相关文章

原文

47 seconds: A villain origin story

Wednesday, 11:15 AM. I'm at the PureGym entrance doing the universal gym app dance. Phone out, one bar of signal that immediately gives up because apparently the building is wrapped in aluminum foil. Connect to PureGym WiFi. Wait for it to actually connect. Open the app.

"Warming up..."

The entire feed loads—suggested workouts I'll never do, my "recent" activities from 2024, a motivational quote that makes me question humanity and myself. Oh, and would I like to enable push notifications? No. How about this special offer for personal training? Also no.

Finally, I can tap "Gym access." Loading spinner. The QR code materializes eventually like it's 2000 and I'm downloading a JPEG on dial-up. Someone behind me clears their throat. Scan at the pod, the barrier grunts open.

Total time: 47 seconds.

I do this 6 days a week. That's 282 seconds of my life every week just trying to enter a building. As someone who's spent more than a decade optimising iOS apps for a living, watching myself suffer through this UX disaster daily was starting to feel like cosmic punishment.

Meanwhile, Amazon Fresh lets people walk out with £200 of groceries without even touching their phone. But here I am, performing a full mobile obstacle course just to lift heavy things and put them back down.

The eight-year PIN mystery

Before we dive into the technical meat, let me share the security contradiction that made me question reality itself.

I've been using the same 8-digit PIN code at PureGym turnstiles for eight years. EIGHT. YEARS. Same code. Never changed. Never expired. It survived COVID, three prime ministers, and the entire rise and fall of NFTs. I could literally tattoo this PIN on my forehead and use it as biometric authentication at this point.

But the QR code in the app? That bad boy refreshes every 60 seconds like it's protecting nuclear launch codes.

Think about this for a second. The physical keypad -- exposed to British weather, coated in a mysterious film of protein shake and regret, probably being livestreamed to TikTok by someone's ring doorbell -- accepts my ancient PIN without question. But the digital QR code needs cryptographic rotation that would make the NSA jealous.

This is peak security theatre. It's like having a £10,000 smart lock on your front door while leaving the windows open.

How I learnt to stop worrying and love mitmproxy

My first approach was embarrassingly naive. "I'll just screenshot the QR code and add it to Apple Wallet as a static image!"

Reader, I actually did this. Created a pretty pass, added my screenshot, proudly walked to the gym on Wednesday with my "innovation." The scanner beeped angrily at me like I'd personally insulted its mother. The screenshot from Monday was already dead.

That's when I discovered PureGym's QR codes are dynamic -- while technically they expire after about a week, the app refreshes them every minute for some reason.

But this raised an important question: if security was so critical, why does my prehistoric PIN still work everywhere?

Following the breadcrumbs on GitHub

Time to see if I was alone in my frustration. Now, most people would start by Googling. But we're engineers. We go straight to the source of truth: GitHub.

Searching for "PureGym" on GitHub is like opening a time capsule of developer frustration. Beautiful.

But nobody had cracked Apple Wallet. The gauntlet was thrown.

These digital archaeologists had unearthed the authentication endpoint:

POST https://auth.puregym.com/connect/token

grant_type=password&username={EMAIL}&password={PIN}&scope=pgcapi offline_access

Authorization: Basic cm8uY2xpZW50Og==

Decode that Base64 and you get ro.client: -- yes, that's a colon at the end. No secret. Just vibes. It's like leaving your house key under the doormat, but the door is also unlocked.

The crown jewel? Your 8-digit gym door PIN is your API password and you most likely didn't set it yourself. The same PIN that hasn't changed since the iPhone 8 was cutting-edge technology. I'm starting to think PureGym's security model was designed by someone with a very specific sense of humour.

Proxying my way to enlightenment

Time to see what the app actually does.

One could notice that at this point the PureGym API is basically crowd-sourced documentation all over Github, but I wanted to see the actual flow myself, so I needed to intercept its traffic, which meant proxy tools. The usual suspects: Charles Proxy, mitmproxy, Proxyman -- pick your poison.

Setting up a proxy in 2025 should be trivial, but mobile apps always had trust issues. Some certificate dancing later (shoutout to SSL Kill Switch and objection for making this bearable), I had traffic flowing:

GET https://capi.puregym.com/api/v2/member/qrcode # Note how auth is not versioned, this is v2, but all other apis are mostly v1s

{
  "QrCode":"exerp:checkin:<part1>-<part2>-<part3>",
  "RefreshAt":"2025-08-14T12:08:27.4349618Z",
  "ExpiresAt":"2025-08-21T12:02:27.4349618Z",
  "RefreshIn":"0:01:00",
  "ExpiresIn":"167:55:00"
}

# part1 is some static id, part2 is the now() timestamp, and part3 seems to change every time so probably is a salt

Look at that response. It literally tells me when to refresh despite a different expiration time. It's like the API is saying "Hey buddy, I know this is odd, but can you poll me every minute? Thanks, love you too."

PassKit: Apple's forgotten child

Here's something many iOS developers don't know: Apple Wallet passes aren't just static cards. They're basically tiny apps that can update themselves, send push notifications, and even react to your location. The framework is called PassKit, it's been around since iOS 6, and approximately 7 people have ever used it properly (definitely not me).

Building a pass requires:

  1. A JSON manifest (easy)
  2. Images in very specific dimensions (annoying)
  3. Cryptographic signatures (hello darkness my old friend)
  4. A web service for updates (time to write some Swift)

The certificate signing process deserves its own horror story but I will save it for my memoirs. As we all know, Apple hates us, and this particular framework is a good example of that. You need:

  • A Pass Type ID certificate from Apple Developer Portal
  • Apple's WWDR intermediate certificate
  • OpenSSL commands that look like you're summoning a demon

After three hours of certificate wrestling, I had a working .pkpass file. It's just a ZIP archive with delusions of grandeur, but when it works, it feels like magic (and when it doesn't, you can't easily tell because Xcode Simulator seems to treat Wallet passes differently from the actual devices).

The Swift backend nobody asked for

Most PassKit tutorials use Node.js. But I'm a Swift developer and believe in suffering, so I built the backend in Vapor.

Why? Because I could. And for type safety. But mostly because I could.


app.post("v1", "devices", ":deviceId", "registrations", ":passType", ":serial") { req async in

let pushToken = req.body["pushToken"]

return Response(status: .created)
}


app.get("v1", "passes", ":passType", ":serial") { req async -> Response in

if nothingChanged {
return Response(status: .notModified)
}


let freshPass = try await generateNewPass()
return Response(body: freshPass, headers: ["Content-Type": "application/vnd.apple.pkpass"])
}

The best part of working with Apple Walet passes is this: when the QR code needs updating, I send a silent push notification. The device wakes up, fetches the new pass, updates the QR code, and the user never knows. It's like having a butler who refreshes your gym pass while you sleep.

The Great Gym Heist

Apple Wallet passes can appear on your lock screen when you're near specific locations. So I figured, as I travel around the UK a lot, I might as well stop by random PureGyms for an occasional bench press, so I needed coordinates for every PureGym in the UK. There are some location lists on the official website, but no coordinates.

Then I found it. The motherlode:

GET https://capi.puregym.com/api/v1/gyms/ # Back to v1, but judging by lots of null fields it used to do more stuff


[
{
"id": 318,
"name": "Yeovil Houndstone Retail Park",
"latitude": "50.945449",
"longitude": "-2.671796",
"email": {...},
"gymOpeningHours": {...},

}
]

I scraped all locations just in case, and add the nearest to the generated pass. Now my membership appears on the lock screen whenever I'm near PureGyms in the country.

The only downside? My local PureGym is in a shopping center. Now my pass appears every time I go to M&S. Buying milk has become a moral dilemma -- am I shopping or avoiding the gym that's literally behind the wall?

The Apple Watch support

Plot twist: Apple Wallet passes automatically sync to Apple Watch. No extra code needed. Double-tap the side button, scan with my wrist, I'm in.

Time: 3 seconds.

That's a 93% reduction in entry time. As someone who optimises code for a living, this number makes me inappropriately happy.

 

Numbers that matter (to me)

Let's talk metrics, because what's a technical blog post without unnecessary statistics:

  • Original app time: 47 seconds
  • Apple Wallet time: 3 seconds
  • Time saved per visit: 44 seconds
  • Visits per week: 6 (I skip Sundays, God rested, so do I)
  • Annual time saved: 3.8 hours
  • Number of times other gym members have asked "is there an app for that?": 23
  • Number of times I've had to explain this isn't official: 23
  • Number of times they've asked me to make them one: 23
  • Number of times I've had to explain copyright law: 23

Bonus round

Since I was already deep in PureGym's API, I figured why not add gym capacity to my Home Assistant setup? The attendance endpoint is beautiful:

GET https:

{
"totalPeopleInGym": 29,
"totalPeopleInClasses": 0
"maximumCapacity": 0,
}

I made a few cards for Home Assistant dashboard that hangs next to my door that definitely help me go to the gym more (or to justify not going because it's too busy):

I was also wondering if I culd prove, with data, that I'm avoiding the gym efficiently but actually I usually come there at their least busy times so I must be a natural.

The uncomfortable engineering truth

This whole project took a weekend. A weekend to solve a problem that PureGym has had years to address.

But here's what I learnt: sometimes the best features come from outside the organization. PureGym probably has a roadmap, sprint planning, and very good reasons for not implementing Apple Wallet. Maybe it's not a priority. Maybe they have data showing only 0.3% of users would use it. Maybe their KPIs are based on the number of online classes previewed in the app, and forcing users to see them every time the app loads secures someone's annual bonus.

Maybe someone in product genuinely believes the current app experience is fine (bless their heart).

But users don't care about your Jira tickets. They care about experiences. And sometimes, those of us on the outside, armed with mitmproxy and too much free time, can prototype those experiences faster than a planning meeting can be scheduled.

The ethical elephant

Am I technically violating terms of service? Probably. Could PureGym break this tomorrow? Absolutely. Should I be doing this? Gestures vaguely at the 47-second entry time.

I follow some ground rules:

  • Cache everything (in case one's API is held together with prayers)
  • No automation beyond personal use
  • Don't share the actual service (hence the 23 explanations)
  • Be ready for it to break any moment

Should I package this up properly? Probably not: it's a proof of concept that solves my specific problem. Plus, PureGym would probably just hire me to shut it down, and I'm not ready for that level of corporate responsibility.

What's next?

The possibilities are endless and mostly ridiculous:
Shame Notifications: "You were literally 100 meters from the gym and walked past it"
SaaS Dreams: Package this properly, get sued immediately, become a cautionary tale at product management conferences

For now, I'm enjoying my 3-second gym entry and the knowledge that I've optimised away 3.8 hours of annual friction. In the grand scheme of things, it's meaningless. But in the petty scheme of things? It's everything.

And if you're from PureGym reading this—let's talk. I've already done half the work for you. 😉


Working on something that needs iOS expertise, reverse engineering skills, or someone who obsesses over shaving 44 seconds off everyday interactions? I build exceptional mobile experiences and occasionally solve problems that shouldn't exist. Drop me a line at [email protected].

联系我们 contact @ memedata.com