Cryptographic App Licensing for macOS & iOS
Unforgeable licenses. Stripe subscriptions. Multi-app support. Zero tracking.
Tessera is a complete, self-contained licensing platform for macOS and iOS apps distributed outside the App Store. Fork this repo, configure your app, and you have:
- Ed25519 signed license keys — cryptographically unforgeable
- Multi-app support — one Tessera repo manages licensing for all your apps
- Hardware-anchored trials — tamper-resistant, clock-manipulation-proof
- Remote revocation — via a static JSON file on your domain (no servers needed)
- Instant revocation enforcement — checks on every app foreground, no 24h wait
- Device seat limiting — restrict each license to N machines, server-enforced
- Stripe subscription billing — automatic license delivery and renewal
- Management dashboard — multi-app tabs, generate signed keys, revoke licenses
- GitHub Action CI — generate licenses per-app from anywhere
- Marketing site — glassmorphic GitHub Pages site ready to deploy
- Dual App Store / Direct distribution — single codebase, compile-time flag separates builds
- TestFlight support — App Store scheme covers both production and TestFlight
All of this runs on free infrastructure: GitHub Actions, GitHub Pages, and Cloudflare Workers (free tier).
Tessera/
├── Sources/Tessera/ # Swift Package — the library you import
│ ├── Core/ # License validator, revocation, keychain, build info helpers
│ ├── Trial/ # Hardware-anchored trial system
│ ├── Security/ # Binary integrity checker
│ ├── UI/ # SwiftUI gate, activation view, status badge
│ └── Types/ # License, state, config, error types
├── Tests/TesseraTests/ # Unit tests
├── Tools/ # CLI, Stripe worker, setup script
│ ├── tessera_cli.py # License generation & management CLI
│ ├── stripe_worker.js # Cloudflare Worker for Stripe webhooks
│ ├── wrangler.toml # Cloudflare Worker config
│ ├── setup.sh # One-command setup wizard
│ └── requirements.txt # Python dependencies
├── Site/ # GitHub Pages site (deploy from this repo)
│ ├── index.html # Marketing page
│ ├── dashboard.html # Multi-app license management dashboard
│ ├── checkout.html # Stripe checkout page template
│ ├── apps/<app>/licensing/ # Per-app license & revocation data
│ └── CNAME # Custom domain config
├── .github/workflows/ # GitHub Actions
│ ├── tessera-generate-license.yml # Per-app license generation
│ ├── tessera-renew-license.yml # Per-app subscription renewal
│ └── static.yml # GitHub Pages deployment
├── Package.swift # Swift Package Manager manifest (macOS 13+, iOS 16+)
├── tessera.config.example.json # Configuration template
├── INTEGRATION_GUIDE.md # Step-by-step integration docs
├── WHY_TESSERA.md # Comparison with alternatives
└── LICENSE # MIT
Click Fork on GitHub, or:
gh repo create my-licensing --template blaineam/tessera --publiccd tessera
chmod +x Tools/setup.sh
./Tools/setup.shThis will:
- Generate your Ed25519 keypair
- Create
tessera.config.jsonfrom the template - Print the public key to embed in your app
Go to Settings → Secrets and variables → Actions and add:
| Secret | Value |
|---|---|
TESSERA_PRIVATE_KEY |
Shared fallback private key (PEM) |
TESSERA_PRIVATE_KEY_<APP> |
Per-app private key, e.g. TESSERA_PRIVATE_KEY_ARI (optional, overrides shared) |
And optionally (for email delivery):
| Secret | Value |
|---|---|
SMTP_USERNAME |
Email account for license delivery |
SMTP_PASSWORD |
Email password or app password |
SMTP_FROM |
Sender address (e.g. noreply@yourdomain.com) |
And optionally (for Stripe):
| Secret | Value |
|---|---|
STRIPE_SECRET_KEY |
sk_live_... from Stripe |
STRIPE_WEBHOOK_SECRET |
whsec_... from Stripe |
In Xcode: File → Add Package Dependencies → enter your Tessera repo URL.
Or in Package.swift:
.package(url: "https://github.com/yourname/Tessera", branch: "main")Then in your app:
import Tessera
@MainActor
let tessera = Tessera(configuration: .init(
publicKeyBase64: "YOUR_PUBLIC_KEY_FROM_SETUP",
revocationURL: URL(string: "https://yourdomain.com/apps/myapp/licensing/revoked.json")!,
appIdentifier: "com.yourcompany.yourapp",
appDisplayName: "Your App"
))
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
#if DIRECT_DISTRIBUTION
.tesseraGate(tessera)
#endif
}
}
}Note: Tessera uses a compile-time
DIRECT_DISTRIBUTIONflag to enable licensing only in direct distribution builds. See Dual Distribution below for setup instructions. The default (App Store) build has no licensing code compiled in at all.
Create the licensing data files in your Site directory:
mkdir -p Site/apps/myapp/licensing
echo '{"licenses":[],"updated":""}' > Site/apps/myapp/licensing/licenses.json
echo '{"revoked":[],"messages":{},"updated":""}' > Site/apps/myapp/licensing/revoked.jsonEdit .github/workflows/tessera-generate-license.yml and add your app to the app input choices:
inputs:
app:
type: choice
options:
- myappThe Site/ directory is deployed automatically to GitHub Pages via the included static.yml workflow on every push to main.
Via the dashboard at https://yourdomain.com/dashboard.html:
- Connect with a GitHub PAT
- Configure your app with its slug, licenses path, and revocation path
- Optionally paste your Ed25519 private key for client-side signing
- Click "+ New License" — keys are generated and optionally emailed
Via GitHub Actions UI: go to Actions → "Tessera: Generate License" → Run workflow
Via CLI:
python3 Tools/tessera_cli.py generate \
--private-key keys/private.pem \
--tier pro --duration 365Tessera manages licensing for multiple apps from a single repo. Each app gets:
- Its own data directory:
Site/apps/<app-slug>/licensing/ - Its own private key (optional):
TESSERA_PRIVATE_KEY_<APP_UPPER>secret - Its own tab in the dashboard
The generate and renew workflows accept an app input that determines:
- Which private key to use (tries
TESSERA_PRIVATE_KEY_<APP>, falls back toTESSERA_PRIVATE_KEY) - Where to write license data (
Site/apps/<app>/licensing/)
The dashboard supports multiple apps via tabs. Each app is configured with:
- Name: Display name
- Slug: Lowercase identifier that matches the workflow's
appchoice (e.g.ari) - Licenses path: Path to
licenses.jsonin the repo (e.g.Site/apps/ari/licensing/licenses.json) - Revocation path: Path to
revoked.jsonin the repo
Add apps in the initial setup screen or via Settings.
Encryption note: Any PII (customer emails, names) stored in license data is encrypted end-to-end in the dashboard. The encryption key never leaves your browser, so GitHub (the storage backend) cannot read customer data at rest.
Support both App Store and direct distribution from a single codebase using separate Xcode schemes and a compile-time flag.
Why not runtime detection? StoreKit 2's
AppTransaction.sharedis unreliable on macOS TestFlight — it can throwSKInternalErrorDomainerrors instead of returning the expected.sandboxenvironment. A compile-time flag is deterministic and never fails.
In Xcode, go to Project → Info → Configurations and duplicate your existing Release configuration. Name it Release-Direct.
Select your target (not the project), go to Build Settings → Swift Compiler - Custom Flags → Active Compilation Conditions, and add DIRECT_DISTRIBUTION to the Release-Direct configuration only.
| Scheme | Purpose | Launch Config | Archive Config |
|---|---|---|---|
| MyApp | App Store / TestFlight (default) | Debug |
Release |
| MyApp-Direct | Notarized direct distribution | Debug |
Release-Direct |
The default scheme uses standard Release — no special flags, no Tessera gate compiled in. The Direct scheme uses Release-Direct which defines DIRECT_DISTRIBUTION, so the Tessera licensing gate is included.
import Tessera
@main
struct MyApp: App {
var body: some Scene {
WindowGroup {
ContentView()
#if DIRECT_DISTRIBUTION
.tesseraGate(tessera)
#endif
}
}
}- App Store / TestFlight: Archive with the
MyAppscheme → Upload to App Store Connect - Direct distribution: Archive with the
MyApp-Directscheme → Notarize and distribute - Xcode Cloud: Set the workflow to use the
MyAppscheme for TestFlight builds
If you have exhaustive switches on TesseraState, all cases still apply in the direct distribution build:
switch tessera.state {
case .licensed(let license): // Valid license
case .trial(let days): // Trial period active
case .expired(let license): // License expired
case .revoked(_, let msg): // License revoked
case .trialExpired: // Trial ended
case .unlicensed: // No license or trial
case .appStore: // Only reachable if using runtime detection
}The .appStore state is only relevant if you use the optional runtime tesseraGateIfNeeded modifier with TesseraBuildInfo.resolve(). With the compile-time approach, the gate is never applied on App Store builds, so .appStore is not reachable.
Revoked licenses are enforced via a static revoked.json file:
{
"revoked": ["license-uuid-1", "license-uuid-2"],
"messages": {
"license-uuid-1": "Transferred to a new key"
},
"updated": "2026-04-05T12:00:00Z"
}Tessera checks the revocation list:
- On every app launch (during
evaluate()) - On every app foreground (via
recheckRevocation(), called automatically by the gate modifier)
Foreground checks always bypass the cache and fetch the latest list. There's no 24-hour wait for revocations to take effect.
If the revocation server is unreachable, Tessera uses the cached list within the offline grace period (default: 30 days). After the grace period expires without a successful check, the app requires connectivity.
Tessera includes a complete Stripe subscription billing pipeline:
Customer → Checkout Page → Stripe → Webhook → Cloudflare Worker → GitHub Action → License Key → Email
↓
Subscription Renewal → New License → Email
-
Create Stripe Products & Prices in your Stripe Dashboard
-
Configure
checkout.htmlwith your Stripe publishable key and Price IDs -
Deploy the Cloudflare Worker:
cd Tools npx wrangler secret put STRIPE_WEBHOOK_SECRET npx wrangler secret put STRIPE_SECRET_KEY npx wrangler secret put GITHUB_TOKEN npx wrangler secret put GITHUB_REPO # yourname/tessera npx wrangler secret put GITHUB_WORKFLOW_ID # tessera-generate-license.yml npx wrangler deploy
-
Add the webhook URL in Stripe Dashboard → Webhooks:
- URL:
https://tessera-stripe.yourname.workers.dev/webhook - Events:
checkout.session.completed,invoice.paid,customer.subscription.deleted
- URL:
-
Set metadata on your Stripe Prices (in the Stripe Dashboard):
tier:personal,pro, orteamduration_days:365(or30for monthly)features:0
Restrict each license key to a maximum number of simultaneous devices:
let tessera = Tessera(configuration: .init(
publicKeyBase64: "YOUR_KEY",
revocationURL: URL(string: "https://yourdomain.com/apps/myapp/licensing/revoked.json")!,
appIdentifier: "com.yourcompany.yourapp",
appDisplayName: "Your App",
trialRegistryURL: URL(string: "https://tessera.yourname.workers.dev")!,
trialRegistrySecret: "YOUR_SECRET",
maxDevicesPerLicense: 3 // 0 = unlimited (default)
))The activation system uses the same Cloudflare Worker as the trial registry.
| Attack | Defense |
|---|---|
| Forge license | Ed25519 signature — computationally infeasible |
| Patch binary | SecCodeCheckValidity runtime integrity check |
| Reset trial (delete app) | Keychain + hidden file persist |
| Reset trial (delete Keychain) | Hidden file persists; any anchor = trial started |
| Clock manipulation | Monotonic date tracking detects backwards clock |
| Copy trial between Macs | Hardware fingerprint (IOPlatformUUID) mismatch |
| Share license globally | Device seat limiting — server-enforced max devices |
| MITM activation/trial | HMAC-authenticated requests & responses |
| MITM revocation check | HTTPS + JSON schema validation |
| Parameter | Type | Default | Description |
|---|---|---|---|
publicKeyBase64 |
String | required | Ed25519 public key (base64, 32 bytes) |
revocationURL |
URL | required | URL to revoked.json |
trialDurationDays |
Int | 14 | Trial length (0 = no trial, license required immediately) |
appIdentifier |
String | required | Bundle ID (used for Keychain namespace) |
offlineGracePeriodDays |
Int | 7 | Days without revocation check allowed |
revocationCheckIntervalHours |
Int | 24 | Revocation cache TTL (foreground checks always bypass) |
trialSalt |
String | "tessera-v1" | Salt for trial tokens (change between major versions) |
purchaseURL |
URL? | nil | Link to purchase page |
appDisplayName |
String | "App" | Name shown in activation UI |
trialRegistryURL |
URL? | nil | Cloudflare Worker URL for server-side trials + activation |
trialRegistrySecret |
String? | nil | Shared secret for Worker authentication |
maxDevicesPerLicense |
Int | 0 | Max devices per license (0 = unlimited) |
expectedTeamID |
String? | nil | Apple Team ID for binary signing verification |
responseVerificationKeyBase64 |
String? | nil | Ed25519 key for server response verification |
allowedOrigin |
String? | nil | CORS origin for trial/activation API |
MIT — free for commercial and open-source use.
Copyright (c) 2026 Blaine Miller