Breaking Your Own Paywall • Then Fixing It
The most common way indie iOS apps get their premium features pirated isn't fancy reverse engineering • it's trusting the client. This tutorial builds a dummy app with a deliberately vulnerable "pro check", shows how trivially mitmproxy and a Theos tweak defeat it, then fixes it the way you actually should: server-side validation.
Everything here is done against a dummy app you build yourself. Do not point any of this at apps you didn't write or don't have permission to test.
The vulnerable app: it calls your server's /api/entitlement endpoint and unlocks premium if the response says so. Three flaws we'll exploit: the check is a simple JSON flag, there's no certificate pinning, and the unlock decision is enforced client-side.
import Foundation
final class PaywallManager: ObservableObject {
@Published var isPro = false
// FLAW #1: the app trusts a plain JSON response
// FLAW #2: no cert pinning, so any proxy can answer for your server
// FLAW #3: "isPro" is just a local bool • whoever controls it wins
func checkEntitlement() {
let url = URL(string: "https://api.yourapp.dev/api/entitlement")!
var req = URLRequest(url: url)
req.setValue("Bearer \(savedToken())", forHTTPHeaderField: "Authorization")
URLSession.shared.dataTask(with: req) { data, _, _ in
guard let data,
let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let pro = json["pro"] as? Bool else { return }
DispatchQueue.main.async {
self.isPro = pro // whole paywall rides on this
}
}.resume()
}
private func savedToken() -> String {
UserDefaults.standard.string(forKey: "auth_token") ?? ""
}
}
// Your dummy backend • the honest version of this endpoint
app.get("/api/entitlement", async (req, res) => {
const user = await getUserFromToken(req.headers.authorization);
if (!user) return res.status(401).json({ error: "unauthorized" });
// In the vulnerable version, "pro" comes straight from your DB flag
// with no proof behind it. That's what makes the attack below work.
res.json({ pro: user.isPro });
});
Attack A: mitmproxy rewrites the response
Install your dummy app on a device, point it at mitmproxy, and install the CA cert (mitm.it from the device). Because the app doesn't pin certificates, the proxy can impersonate your server and flip "pro": false to "pro": true. The app never knows.
from mitmproxy import http
import json
TARGET_PATH = "/api/entitlement"
class PaywallCrack:
def response(self, flow: http.HTTPFlow) -> None:
if TARGET_PATH in flow.request.path:
try:
data = json.loads(flow.response.content)
data["pro"] = True # flip the flag the client trusts
flow.response.content = json.dumps(data).encode()
print(f"[+] Patched entitlement for {flow.request.pretty_url}")
except Exception as e:
print(f"[-] Could not patch: {e}")
addons = [PaywallCrack()]
# Terminal 1: start the proxy with the addon
mitmproxy -s crack_addon.py -p 8080
# Device setup:
# 1. Wi-Fi settings -> HTTP proxy -> your computer's IP, port 8080
# 2. Safari on device -> http://mitm.it -> install the CA cert
# 3. iOS 15+: Settings > General > About > Certificate Trust Settings
# -> enable full trust for mitmproxy
# 4. Launch your dummy app -> premium unlocks instantly
Attack B: Theos tweak bypasses the check entirely
Even with pinning, the flaw remains: the decision is enforced client-side. A tweak hooks the setter/property and forces isPro = YES. No network involved at all. This is why "the server says no" isn't enough if the app decides locally.
#import
// Hook the PaywallManager class from your own dummy app.
// Replace "YourDummyApp" with the actual module/class name
// (use %ctor + objc_getClass to confirm it exists).
%hook PaywallManager
- (BOOL)isPro {
return YES; // every read of the flag now returns premium
}
- (void)setIsPro:(BOOL)pro {
%orig(YES); // even if the server says false, it stays true
}
%end
%ctor {
%init;
}
TARGET := iphone:clang:latest:14.0
INSTALL_TARGET_PROCESSES = YourDummyApp
include $(THEOS)/makefiles/common.mk
TWEAK_NAME = PaywallBypass
PaywallBypass_FILES = Tweak.x
PaywallBypass_CFLAGS = -fobjc-arc
include $(THEOS_MAKE_PATH)/tweak.mk
Fixing it properly
The fix isn't one trick • it's moving the trust to the server and making client tampering expensive. Priority order:
- Server validates the real purchase. Send the StoreKit 2 signed transaction (JWSTransaction) to your server. Verify the signature chain against Apple's root cert server-side. Never trust a client's "I bought this" claim.
- Server issues an entitlement token. Sign it (Ed25519/JWT) and embed an expiry + device/user binding. The client can present it, but can't forge one.
- Entitlement gates server-side data, not UI. The pro feature should require something only your server will give to verified buyers (an API returning real content, a decryption key for downloaded assets, etc.). Hiding a button is not protection • as Attack B showed, UI flags are trivially hooked.
- Certificate pinning raises the bar against proxy attacks (Attack A) • makes the mitm approach fail unless they also patch the pin. It's a speed bump, not a wall; keep it alongside real validation.
- Detect tampering • check for injected dylibs/tweaks, missing App Store signature, and odd entitlement responses (e.g., a "pro" token whose signature doesn't verify). Log it server-side and rate-limit, don't hard-brick legit users.
import Foundation
import StoreKit
final class PaywallManager: ObservableObject {
@Published var isPro = false
// StoreKit 2: transactions are cryptographically signed by Apple.
// We send the signed payload to OUR server • the client never
// decides anything on its own.
func syncEntitlement() async {
guard let token = savedToken() else { return }
// Gather current entitlement transactions from StoreKit 2
var signedTxns: [String] = []
for await result in Transaction.currentEntitlements {
if case .verified(let txn) = result {
// txn.jsonRepresentation is the JWS the server will verify
signedTxns.append(String(data: txn.jsonRepresentation(), encoding: .utf8) ?? "")
}
}
var req = URLRequest(url: URL(string: "https://api.yourapp.dev/api/verify")!)
req.httpMethod = "POST"
req.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = try? JSONEncoder().encode(["transactions": signedTxns])
// Pinned session • see PinnedSession below
let (data, resp) = try? await PinnedSession.shared.data(for: req)
guard let data, (resp as? HTTPURLResponse)?.statusCode == 200 else {
isPro = false
return
}
// Server returns a SIGNED entitlement token, short-lived.
// Its presence means Apple's receipt checked out server-side.
if let json = try? JSONDecoder().decode(EntitlementResponse.self, from: data) {
isPro = json.isValid // server-verified, not client-decided
saveEntitlementToken(json.token)
}
}
}
// Certificate pinning: mitmproxy's CA is no longer trusted,
// killing Attack A. Note: this alone does NOT stop Attack B •
// which is exactly why the server-side checks above matter most.
final class PinnedSession: NSObject, URLSessionDelegate {
static let shared = URLSession(configuration: .default, delegate: PinnedSession(), delegateQueue: nil)
private let pinnedSPKI = "BASE64_SPKI_SHA256_OF_YOUR_CERT" // ssl-pin-digest of your server cert
func urlSession(_ session: URLSession, didReceive challenge: URLAuthenticationChallenge,
completionHandler: @escaping (URLSession.AuthChallengeDisposition, URLCredential?) -> Void) {
guard let serverTrust = challenge.protectionSpace.serverTrust,
SecTrustEvaluateWithError(serverTrust, nil) else {
completionHandler(.cancelAuthenticationChallenge, nil); return
}
// Compare the server cert's SPKI hash to our pinned value
var trust: SecTrust?
SecTrustCopyAnchorCertificates(serverTrust) // (keep default eval)
if let certs = SecTrustGetCertificateChain(serverTrust),
let leaf = certs.first,
let spkiData = spkiHash(of: leaf),
spkiData == pinnedSPKI {
completionHandler(.useCredential, URLCredential(trust: serverTrust))
} else {
completionHandler(.cancelAuthenticationChallenge, nil)
}
}
private func spkiHash(of cert: SecCertificate) -> String? {
// Extract SubjectPublicKeyInfo and SHA-256 it
guard let der = SecCertificateCopyData(cert) as Data?,
let spki = extractSPKI(from: der) else { return nil }
return Data(SHA256.hash(data: spki)).base64EncodedString()
}
private func extractSPKI(from der: Data) -> Data? {
// Parse the DER to pull out the public key info block
// (use a small ASN.1 lib or Heimdall/SwiftSSL for brevity)
return nil // implement with your ASN.1 parser of choice
}
}
struct EntitlementResponse: Codable {
let isValid: Bool
let token: String
}
private func savedToken() -> String? {
UserDefaults.standard.string(forKey: "auth_token")
}
private func saveEntitlementToken(_ t: String) {
UserDefaults.standard.set(t, forKey: "entitlement_token")
}
// This is where the real check happens. The client can lie all it
// wants • this endpoint only trusts Apple's signature.
const { createPublicKey, verify, sign } = require("crypto");
const jwt = require("jsonwebtoken");
// Apple's root CA • fetch once and cache
// https://www.apple.com/certificateauthority/AppleRootCA-G3.cer
const APPLE_ROOT_PUB = loadAppleRootPublicKey();
app.post("/api/verify", async (req, res) => {
const user = await getUserFromToken(req.headers.authorization);
if (!user) return res.status(401).json({ error: "unauthorized" });
const txns = req.body.transactions || [];
let validPurchase = false;
for (const jws of txns) {
// 1. Verify the JWS signature against Apple's root cert chain
// (use a JWS lib like jose • it does the x5c chain check)
const { payload, header } = await jose.compactVerify(jws, APPLE_ROOT_PUB)
.catch(() => ({ payload: null }));
if (!payload) continue; // forged/garbage txn • ignore
const txn = JSON.parse(payload);
// 2. Confirm the txn belongs to THIS user's app + is not refunded
if (txn.appAppleId === YOUR_APPLE_APP_ID && !txn.revocationDate) {
validPurchase = true;
}
}
// 3. Issue a short-lived SIGNED entitlement token
const token = sign(
{ sub: user.id, pro: validPurchase, exp: Math.floor(Date.now()/1000) + 3600 },
process.env.ENTITLEMENT_SECRET,
{ algorithm: "EdDSA" } // Ed25519 • client can't forge this
);
res.json({ isValid: validPurchase, token });
});
// 4. Every premium API endpoint re-checks the token
function requirePro(req, res, next) {
try {
const claims = jwt.verify(req.headers["x-entitlement"], process.env.ENTITLEMENT_SECRET);
if (claims.pro) return next();
} catch (_) {}
res.status(403).json({ error: "pro required" });
}
// Example: pro content is gated HERE, not by a hidden button
app.get("/api/pro-content", requirePro, async (req, res) => {
res.json({ content: await loadProContent() });
});
Why this holds up
Replay the attacks against the fixed version:
- Attack A (mitmproxy) • dies at the pin. Even if the attacker strips the pin with another tweak, flipping
isValidin the response doesn't matter, because the signed token won't verify against your server's secret, and premium content itself comes fromrequirePro-gated endpoints. - Attack B (Theos hook on
isPro) • the flag still gets forced to YES, so the UI unlocks. But the "pro" features now need real data from/api/pro-content, which checks the Ed25519-signed entitlement server-side. The attacker is stuck staring at a premium UI with nothing behind it. That's the whole point: never gate value with a client-side boolean.
Nothing is unbreakable • a determined attacker with a jailbroken device can go after more. But every step up the chain (pinning → signed entitlements → server-gated content) multiplies the effort from "15 minutes with mitmproxy" to "real reverse engineering work," which for most indie apps is enough.
GML Heart System • Full Setup
This is a complete health system for GameMaker Studio 2 / GameMaker Studio. It tracks maximum health and current health, draws a row of hearts in the GUI layer, and provides a reusable function that any object can call when the player takes damage.
You need two sprites:
- spr_heart_full • a filled heart
- spr_heart_empty • the same heart but empty
Create an object called obj_player. Put the Create Event code and the Draw GUI Event code inside it. Create a script called scr_take_damage. Whenever anything should hurt the player, call scr_take_damage(1). When health reaches zero the room restarts.
max_hp = 5;
hp = max_hp;
function scr_take_damage(amount) {
with (obj_player) {
hp -= amount;
if (hp <= 0) {
hp = 0;
room_restart();
}
}
}
for (var i = 0; i < max_hp; i++) {
var xx = 20 + (i * 28);
var yy = 20;
if (i < hp) {
draw_sprite(spr_heart_full, 0, xx, yy);
} else {
draw_sprite(spr_heart_empty, 0, xx, yy);
}
}
SwiftUI • OpenSSL behind a C wrapper
A full iOS app. The only UI is one text label: openssl on or openssl off. Swift never imports OpenSSL. A C file calls OPENSSL_init_crypto, and the bridging header exposes that one function.
Create a SwiftUI app named OpenSSLApp. Add every file below. From the project folder run pod install, then open OpenSSLApp.xcworkspace. Put openssl_wrapper.c in the app target. In the target Build Settings, set the configuration file to OpenSSL.xcconfig, or copy those two settings in by hand.
platform :ios, '15.0'
target 'OpenSSLApp' do
use_frameworks!
pod 'OpenSSL-Universal'
end
SWIFT_OBJC_BRIDGING_HEADER = OpenSSLApp/OpenSSLApp-Bridging-Header.h
HEADER_SEARCH_PATHS = $(inherited) "$(PODS_ROOT)/OpenSSL-Universal/OpenSSL.xcframework/**"
#ifndef OPENSSL_WRAPPER_H
#define OPENSSL_WRAPPER_H
int openssl_is_on(void);
#endif
#include "openssl_wrapper.h"
#include <openssl/crypto.h>
#include <openssl/opensslv.h>
int openssl_is_on(void) {
if (OPENSSL_init_crypto(OPENSSL_INIT_LOAD_CRYPTO_STRINGS, NULL) != 1) {
return 0;
}
return OpenSSL_version_num() > 0;
}
#include "openssl_wrapper.h"
import SwiftUI
@main
struct OpenSSLApp: App {
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
import SwiftUI
struct ContentView: View {
private let on = openssl_is_on() != 0
var body: some View {
Text(on ? "openssl on" : "openssl off")
}
}
Google AdMob Banner Ads in SwiftUI • Full Setup
Complete production-ready banner implementation using the official Google Mobile Ads SDK wrapped for SwiftUI. Add the SDK via SPM, put your App ID in Info.plist, and replace the test unit ID with your own.
import SwiftUI
import GoogleMobileAds
@main
struct YourApp: App {
init() {
MobileAds.shared.start(completionHandler: nil)
}
var body: some Scene {
WindowGroup {
ContentView()
}
}
}
import SwiftUI
import GoogleMobileAds
struct BannerAdView: UIViewRepresentable {
let adUnitID: String
func makeUIView(context: Context) -> BannerView {
let banner = BannerView(adSize: AdSizeBanner)
banner.adUnitID = adUnitID
banner.rootViewController = UIApplication.shared.connectedScenes
.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }?
.rootViewController
banner.load(Request())
return banner
}
func updateUIView(_ uiView: BannerView, context: Context) {}
}
import SwiftUI
struct ContentView: View {
var body: some View {
VStack(spacing: 0) {
Spacer()
Text("Your App Content")
.foregroundColor(.white)
Spacer()
BannerAdView(adUnitID: "ca-app-pub-3940256099942544/2934735716")
.frame(height: 50)
}
.background(Color.black)
.ignoresSafeArea(edges: .bottom)
}
}
Theos Tweak • Alert with Follow Button
Complete Theos tweak that shows a UIAlertController with a Follow button. Uses NSUserDefaults so it only appears once. Change the filter and the X URL as needed.
#import
%hook SpringBoard
- (void)applicationDidFinishLaunching:(id)application {
%orig;
NSUserDefaults *defaults = [NSUserDefaults standardUserDefaults];
if (![defaults boolForKey:@"DevCaspAlertShown"]) {
dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(1.5 * NSEC_PER_SEC)), dispatch_get_main_queue(), ^{
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Hey"
message:@"Follow me on X for more tweaks and projects"
preferredStyle:UIAlertControllerStyleAlert];
UIAlertAction *follow = [UIAlertAction
actionWithTitle:@"Follow"
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *action) {
NSURL *url = [NSURL URLWithString:@"https://x.com/devcasp"];
[[UIApplication sharedApplication] openURL:url options:@{} completionHandler:nil];
}];
UIAlertAction *dismiss = [UIAlertAction
actionWithTitle:@"Later"
style:UIAlertActionStyleCancel
handler:nil];
[alert addAction:follow];
[alert addAction:dismiss];
UIWindow *window = [UIApplication sharedApplication].keyWindow;
[window.rootViewController presentViewController:alert animated:YES completion:nil];
[defaults setBool:YES forKey:@"DevCaspAlertShown"];
[defaults synchronize];
});
}
}
%end
TARGET := iphone:clang:latest:14.0
INSTALL_TARGET_PROCESSES = SpringBoard
include $(THEOS)/makefiles/common.mk
TWEAK_NAME = DevCaspAlert
DevCaspAlert_FILES = Tweak.x
DevCaspAlert_CFLAGS = -fobjc-arc
DevCaspAlert_FRAMEWORKS = UIKit
include $(THEOS_MAKE_PATH)/tweak.mk
Package: dev.casp.alert
Name: DevCasp Alert
Depends: mobilesubstrate
Version: 1.0.0
Architecture: iphoneos-arm
Description: Shows an alert with a follow button
Maintainer: DevCasp
Author: DevCasp
Section: Tweaks
SwiftUI Glass Card • Complete Component
Reusable glass-style card using ultraThinMaterial and a red stroke. Drop it into any SwiftUI file.
import SwiftUI
struct GlassCard: View {
let title: String
let subtitle: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(title)
.font(.headline)
.foregroundColor(.red)
Text(subtitle)
.font(.subheadline)
.foregroundColor(.white.opacity(0.8))
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial)
.cornerRadius(16)
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.red.opacity(0.3), lineWidth: 1)
)
}
}
Intersection Observer • Full Fade-In System
The same pattern used on this site. Elements start invisible and animate when they enter the viewport.
const observer = new IntersectionObserver(entries => {
entries.forEach(entry => {
if (entry.isIntersecting) {
entry.target.classList.add('animate__animated', 'animate__fadeInRight');
observer.unobserve(entry.target);
}
});
}, { threshold: 0.15 });
document.querySelectorAll('.animate-on-scroll')
.forEach(el => observer.observe(el));
CSS Glass Panel • Complete Style
The exact glass treatment used throughout this site.
.glass-panel {
background: linear-gradient(180deg, rgba(255,255,255,0.14), rgba(255,255,255,0.04));
background-color: rgba(18, 16, 22, 0.45);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 22px;
padding: 24px;
box-shadow: inset 0 1px 0 rgba(255,255,255,0.42), 0 18px 40px rgba(0,0,0,0.38);
color: #fff;
}