The problem
Web Iron Shield used a classic offline activation scheme. When a customer bought a license, my keygen tool would take their email and hardware ID, run them through SHA-256 with a secret, and output a serial key. The installed app would do the same computation on first activation and compare — if the keys matched, license valid.
The secret: "WIS_2024_SECRET_KEY_TAQI". Hardcoded. In license_system.py. Inside the compiled .exe that every customer downloads.
PyInstaller-packaged executables are not obfuscated. Anyone with pyinstxtractor can extract the Python source in 30 seconds. Once they have the secret, they can generate valid keys for any email + hardware ID combination — forever, without ever paying.
How I missed it
Honestly? I built the licensing early in the project when I wasn't thinking about scale. One-person shop, small sales, who's going to bother reverse-engineering a $99 tool? That logic works until one copy gets shared on a keygen forum and suddenly you have 5,000 "customers" who paid zero.
During a code review I asked for exactly the kind of question I should have asked myself earlier: "if one copy of this leaks, how bad is it?" The answer was "catastrophic", so it became the first thing to fix.
The fix: server-side generation
The signing secret now lives only on webironshield.com. When a payment webhook fires, the server (not the client) generates the serial key using the secret. The secret never leaves the server, never gets shipped, never appears in any binary.
Two PHP endpoints do the work:
/api/generate_license.php (admin-only, mints keys)
/api/verify_license.php (public, validates keys)
The admin endpoint is protected by a separate admin token (different from the signing secret). Only my internal ordering system knows the admin token, so even if someone finds the endpoint URL they can't generate keys without the token.
Verification flow
Before: client computes expected key locally, compares.
After: client sends email + hardware ID + serial to the verify endpoint. Server re-computes expected key using the server-side secret. Responds valid or invalid.
For the offline-use case (travelling, no internet), the client caches a successful verification for 14 days. If the server is unreachable after that, the client keeps working — network problems don't lock out paying customers. It just re-verifies when possible.
Trade-offs
The main cost is that first-time activation now requires internet. That's fine — you're buying it on the website, you have internet. The other cost is that I need to keep the verification endpoint alive forever. But it's a 2 KB PHP file with no dependencies, so "forever" is extremely cheap.
The benefit is that the cost of a leaked .exe drops from "catastrophic" to "someone might have one pirated copy until they activate it" — and the server-side verification blocks activation for non-matching keys.
The broader lesson
If you're building a licensed product with offline activation: assume every bit of logic and every constant you ship will be read by a determined attacker. That's what "running on someone else's computer" means. The only trustworthy secrets are the ones that never leave your infrastructure.
This applies beyond licensing:
- API keys for paid services? Don't ship them — put a proxy endpoint on your server.
- Algorithm details you want to hide? They're not hidden if the code runs client-side.
- Feature flags? Client flags are UX hints, not security boundaries. Server must re-check.
Migration for existing customers
Existing v2.6 customers had keys generated with the OLD secret. Those keys won't match the new server's secret, so they'd show as invalid after upgrading. The fix was a 30-day transition period where the verify endpoint accepts either secret. After 30 days, I'll drop the old secret and anyone still on v2.6 will need to request a new key (free, auto-issued on request).