Or: how to defend a desktop app when you've already handed the attacker the source code.

Table of contents

  1. The problem
  2. Why boolean checks fail
  3. The core idea: server-gated data
  4. The full architecture
  5. The wire protocol
  6. Picking your stack
  7. Step 1 — Init and the encrypted payload
  8. Step 2 — Password storage done right
  9. Step 3 — Register and license activation
  10. Step 4 — Login and session creation
  11. Step 5 — Hardware binding
  12. Step 6 — Heartbeat and rotating tokens
  13. Step 7 — Fetching the gated data
  14. Step 8 — Anti-relay with sequence counters
  15. Step 9 — Binary integrity
  16. Step 10 — SDK version gating
  17. Step 11 — Rate limiting
  18. Step 12 — Failed login tracking and auto-blacklist
  19. Step 13 — Login anomaly detection
  20. Step 14 — The admin side
  21. Step 15 — The client side
  22. What's real defense and what's theater
  23. Common mistakes
  24. Testing your system
  25. The honest ending

1. The problem

If you've ever shipped a desktop application that people pay for, you've run into this: the moment your code runs on someone else's machine, you've lost control of it.

You can compile it, pack it, obfuscate it, but at the end of the day there's a binary on someone's disk, and that binary is theirs. They can read it, they can patch it, they can run it in a debugger, they can feed it fake network responses, they can do anything they want with it. And every check you put inside that binary to enforce payment runs inside code they control.

This is the fundamental asymmetry of software licensing. The defender has to get every check right. The attacker only has to find one hole.

The good news is that this asymmetry doesn't mean you're helpless. It means you have to shift your thinking. Instead of trying to protect the decision (is this user licensed?), you protect the data (what does a licensed user get that an unlicensed user doesn't have?).

That shift — from protecting decisions to protecting data — is what this entire post is about.

2. Why boolean checks fail

Start with the obvious approach. Somewhere in your code, you write:

def is_licensed(): # check something  return True if not is_licensed(): exit(1) # premium features below 

Anyone with a hex editor and thirty seconds finds that if not is_licensed(): branch and replaces it with a no-op. Now the app runs whether or not it's licensed. Your entire protection was one byte.

You can make it harder to find. Obfuscate the function name, scatter the check across multiple files, compute it from multiple values. All of that helps a little. All of it fails eventually.

Why? Because the result of the check has to be used by the client. And the client is attacker-controlled. The moment the app decides to enable a feature based on a value that lives inside the attacker's memory, the attacker can change that value. It doesn't matter how cleverly you computed it. It only matters that it's local.

This is the first law of client-side security: any decision the client makes, the client can lie about.

You can't get around this by being clever. You have to change the game.

3. The core idea: server-gated data

Here's the shift.

Instead of your app asking "am I licensed?" and getting a yes/no, your app asks the server for the data it actually needs to function. If the server says no, the app doesn't get a boolean False it can patch to True. It gets nothing. And nothing is unfakeable.

Concretely. Imagine a tool with a premium feature that generates reports. The naive design:

if user.is_premium: template = load_local_template() generate_report(template) 

The secure design:

encrypted = server.fetch_gated_data() template = decrypt(encrypted, local_key) generate_report(template) 

The attacker patches if user.is_premium — nothing happens. That was never the gate. The gate is that template doesn't exist on their machine in usable form until the server hands it over, encrypted, tied to their session, bound to their hardware.

They can't patch their way into data they don't have. They can only:

  • Steal a legit user's session and use it live (which you can detect)
  • Break the encryption (which is infeasible with proper crypto)
  • Run a proxy in front of a legit client (which you can defeat with sequence counters)
  • Get the actual data some other way (which is a social problem, not a technical one)

This is the single most important idea in modern license systems. Every technique below is defense in depth around this idea. The idea itself is the foundation.

If your system doesn't have server-gated data, nothing else in this post matters. You're polishing a broken design.

4. The full architecture

Before we write code, look at the whole picture.

┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ CLIENT │ │ SERVER │ │ DATABASE │ │ (desktop) │ │ (FastAPI) │ │ (SQL/JSON) │ └──────┬───────┘ └──────┬───────┘ └──────┬───────┘ │ │ │ │ 1. init │ │ │────────────────────────▶│ │ │ │ create session │ │ │ generate enc_key │ │ │ store key server-side │ │◀────────────────────────│ │ │ sessionid, enc_payload │ │ │ │ │ │ 2. login (user,pass,hwid) │ │────────────────────────▶│ │ │ │ verify password ────────▶ │ │◀────────────────────────│ │ │ check HWID │ │ │ bind session to hwid │ │◀────────────────────────│ │ │ decryption_key, session │ │ │ │ │ │ 3. heartbeat │ │ │────────────────────────▶│ │ │ │ rotate hb_token │ │◀────────────────────────│ │ │ hb_token │ │ │ │ │ │ 4. fetchdata (hb_token, fetch_seq) │ │────────────────────────▶│ │ │ │ verify token │ │ │ verify seq > last_seq │ │ │ encrypt with hwid_key │ │◀────────────────────────│ │ │ encrypted_data │ │ │ │ │ │ decrypt with derived key│ │ │ use data │ │ 

The client never receives the raw gated data. It receives encrypted data that only its own hardware can decrypt. The server never trusts the client's claims about itself — every request re-verifies HWID, session, token, and sequence.

5. The wire protocol

Before writing a line of server code, decide what the client and server actually say to each other. This is your contract, and changing it later is painful because every deployed client breaks.

Every request from the client looks roughly like:

{ "type": "login", "name": "my-app-name", "ownerid": "abc123def456", "username": "user1", "password": "hunter2", "hwid": "9f3a2c7e1b4d...", "sessionid": "optional, set after init", "hb_token": "optional, set after first heartbeat", "fetch_seq": 0 } 

And every response is JSON with at minimum:

{ "success": true, "message": "human-readable", ...action-specific fields } 

Design the protocol so that every request carries the full context needed to verify it. Don't have a "stateful" protocol where the client says one thing on request 1 and another on request 2 — you'll get desync bugs. Every request should be verifiable on its own.

A minimal set of action types:

Action Purpose
init Start a session, get an encrypted payload the client can't yet decrypt
register Create a user with a license key
login Authenticate an existing user with username/password
license Authenticate with just a key (no username/password)
heartbeat Prove the session is alive, get a new rotating token
fetchdata Retrieve the gated payload, encrypted per-HWID

Optional, add later:

Action Purpose
check Lightweight session validity check
setvar / getvar Per-user variables stored server-side
log Client-side event logging
webhook Trigger outbound notifications

That's it. Everything else is a variation. Keep the protocol small.

6. Picking your stack

Server: FastAPI over Flask. Both work. FastAPI gives you:

  • Async request handling out of the box
  • Typed request models via Pydantic (so the request schema is the documentation)
  • Automatic validation (malformed requests get rejected before your code runs)
  • Better performance under concurrent load

For an auth server that gets hit constantly by heartbeats from every client, that last point matters. Flask with sync workers will run out of worker threads at low client counts. FastAPI handles thousands of concurrent requests per worker.

Storage: a real database, or careful file locking. JSON files are fine for a prototype. They stop being fine the moment two users register at the same time, because you get a race condition:

User A: read keys.json → key "ABC123" is unused User B: read keys.json → key "ABC123" is unused User A: mark ABC123 used → write keys.json User B: mark ABC123 used → write keys.json (overwrites A's write) 

Both users think they registered with the same key. The second write wins, and now the audit log is wrong.

Two fixes:

  1. Use SQLite. Transactions handle this for you. Add Postgres when you outgrow it.
  2. If you must use JSON files, wrap every read-modify-write in a per-resource lock:
import threading app_locks = {} locks_guard = threading.Lock() def get_app_lock(app_id): lock = app_locks.get(app_id) if lock is None: with locks_guard: lock = app_locks.setdefault(app_id, threading.Lock()) return lock # Every mutation: with get_app_lock(app_id): data = read_json(f"app_{app_id}_keys.json") # ... mutate ...  write_json(f"app_{app_id}_keys.json", data) 

The lock only works within one process. If you run multiple server workers, they don't share locks and you're back to races. Either pin to one worker or use a real database.

Client: whatever the app is written in. The protocol is HTTP + JSON. The client just has to implement it. The decryption step requires HMAC and hashing primitives, which every language has.

7. Step 1 — Init and the encrypted payload

The very first thing a client does is init. The server:

  1. Generates a random session ID
  2. Generates a random encryption key for this session only
  3. Encrypts a small metadata payload with that key
  4. Stores the key server-side, keyed by the session ID
  5. Returns the session ID and the encrypted blob, but not the key
import secrets, base64, hmac, hashlib, json, time _enc_sessions = {} # session_id -> {enc_key, mac_key, app_id, created, ...}  def encrypt_payload(session_id, app_id, payload): enc_key = secrets.token_bytes(32) mac_key = secrets.token_bytes(32) iv = secrets.token_bytes(16) plaintext = json.dumps(payload, separators=(",", ":")).encode() # PKCS7 pad to block boundary (even though we're using a stream, this  # hides the plaintext length from anyone analyzing ciphertext size)  pad_len = 16 - (len(plaintext) % 16) plaintext += bytes([pad_len]) * pad_len ciphertext = hmac_stream(enc_key, iv, plaintext) mac = hmac.new(mac_key, iv + ciphertext, hashlib.sha256).digest() _enc_sessions[session_id] = { "enc_key": enc_key, "mac_key": mac_key, "app_id": app_id, "created": time.time(), "authenticated": False, "hwid": None, "hb_token": None, "last_hb": None, "client_seq": 0, } return base64.b64encode(iv + ciphertext + mac).decode() def hmac_stream(key, iv, data): """HMAC-SHA256 counter-mode keystream, XORed with the data.""" out = bytearray() for i in range((len(data) + 31) // 32): block = hmac.new(key, iv + i.to_bytes(4, "big"), hashlib.sha256).digest() out.extend(block) return bytes(a ^ b for a, b in zip(data, out[:len(data)])) 

A note on the cipher, because this will get flagged: rolling your own stream cipher is generally a bad idea. The construction above — HMAC-SHA256 in counter mode — is a reasonable PRF-based keystream, but it's not what cryptography engineers would reach for. If you're using the cryptography library for anything else (like Fernet, which is what most people use for at-rest encryption), you already have access to AES-GCM. Use it. It's authenticated, it's constant-time in the important places, it's been reviewed by everyone.

The reason I'm showing the HMAC construction is because it's what a lot of hobbyist auth systems end up writing, and it's instructive to see why it exists (no dep, simple to audit) and why it's inferior to AES-GCM (no formal security proof, easy to misuse, no nonce-misuse resistance).

If you want the honest short version: use AES-GCM. Everything below assumes that level of security.

The init handler itself:

def handle_init(name, ownerid, ver): app = find_app_by_name_and_owner(name, ownerid) if not app: return {"success": False, "message": "Application not found"} if not app.get("enabled", True): return {"success": False, "message": "Application is disabled"} session_id = secrets.token_hex(16) payload_data = { "app_id": app["id"], "app_name": app["name"], "version": app.get("version", "1.0.0"), "session_id": session_id, "nonce": secrets.token_hex(8), "ts": datetime.utcnow().isoformat() + "Z", } encrypted = encrypt_payload(session_id, app["id"], payload_data) return { "success": True, "message": "Initialized", "sessionid": session_id, "encrypted_payload": encrypted, "appinfo": { "version": app.get("version", "1.0.0"), "numUsers": count_users(app["id"]), "numKeys": count_keys(app["id"]), }, } 

The client now has a blob it can't read. It needs to authenticate to get the key. The init step is mostly theater on its own — the payload is just metadata — but it establishes the pattern: the client can't proceed without server cooperation.

Also notice: the server generates a fresh random key per session. This is important. It means if an attacker compromises one session key, they've compromised one session, not the entire app. The key never leaves the server until auth succeeds, and even then it's returned as a value that only works for this session.

8. Step 2 — Password storage done right

This is where almost every tutorial gets it wrong, and where I've seen more real-world breaches than anywhere else. So let's be blunt about it.

You do not encrypt passwords. You don't wrap them in symbols. You don't encrypt-then-hash.

Encryption is reversible by design. If you encrypt a password to store it, you must be able to decrypt it later — which means the key lives somewhere on your server. Any attacker who gets your database and your server code gets every password in plaintext. You've gained nothing.

You hash them. Specifically, you use a slow hash with a per-user salt.

Why hashing and not encryption

Hashing is one-way. You store hash(password), not password. When a user logs in, you compute hash(attempted_password) and compare it to the stored hash. If they match, the password was right.

If an attacker steals your database, they get hashes. Not passwords. And if you did it right, they can't reverse the hashes without brute-forcing each one, which is what we're going to make prohibitively expensive.

Why a salt

Without a salt, the hash of "hunter2" is the same for every user. An attacker can precompute a table:

"password" → 5f4dcc3b5aa765d61d8327deb882cf99 "hunter2" → 2ab96390c7dbe3439de74d0c9b0b1767 "letmein" → 0d107d09f5bbe40cade3de5c71e9e9b7 ...10 billion more... 

Then every hash in your stolen database is a lookup. That's a rainbow table attack.

A salt is a random value generated per user, stored alongside the hash. You hash salt + password. Now every user's "hunter2" produces a different hash. The attacker has to brute-force each user individually, and precomputation is worthless.

Why a slow hash

SHA-256 was designed for speed. A modern GPU can compute billions of SHA-256 hashes per second. If your password has 8 characters from a 62-character alphabet, that's ~218 trillion combinations. Sounds like a lot, but at 10 billion hashes/second, that's 6 hours.

bcrypt, argon2, and scrypt are deliberately slow. They have a "cost" parameter that scales their runtime. Set bcrypt to cost 12 and each hash takes ~250ms. That turns 6 hours into 1.7 million years. Even scaling up the GPU farm doesn't help — the algorithms are memory-hard and don't parallelize the way SHA-256 does.

The right way

import bcrypt def hash_password(password: str) -> str: return bcrypt.hashpw( password.encode("utf-8"), bcrypt.gensalt(rounds=12), ).decode("utf-8") def verify_password(password: str, stored: str) -> bool: try: return bcrypt.checkpw(password.encode("utf-8"), stored.encode("utf-8")) except Exception: return False 

That's it. bcrypt.gensalt handles salt generation, bcrypt.hashpw handles the hashing, bcrypt.checkpw handles verification. No manual salt, no manual comparison, no timing leaks.

bcrypt hashes have a known format: they start with $2a$, $2b$, or $2y$ followed by the cost, salt, and hash all in one string. So you can detect bcrypt hashes just by looking at them:

def is_bcrypt(stored): return isinstance(stored, str) and stored.startswith(("$2a$", "$2b$", "$2y$")) 

Migrating from a worse scheme

If you already have users stored with SHA-256 or plaintext (maybe from an early prototype), you can't easily migrate them all at once — you don't have the plaintext passwords, so you can't re-hash them.

The trick is lazy migration: on each successful login, re-hash with the new scheme and save.

def verify_and_upgrade(password, user): stored = user["password_hash"] if is_bcrypt(stored): return bcrypt.checkpw(password.encode(), stored.encode()), False if is_sha256_hex(stored): ok = hmac.compare_digest(stored, hashlib.sha256(password.encode()).hexdigest()) return ok, ok # needs rehash if matched  # Legacy plaintext  ok = hmac.compare_digest(stored, password) return ok, ok # In the login handler: ok, needs_rehash = verify_and_upgrade(req.password, user) if ok and needs_rehash: user["password_hash"] = hash_password(req.password) save_user(user) 

Over time every active user migrates. Inactive users get migrated on their next login. Everyone else is old and irrelevant.

9. Step 3 — Register and license activation

Registration creates a user account and burns a license key. Two writes have to happen atomically:

  1. Append the new user to the user list
  2. Mark the license key as used

If either fails or if another registration races in between, you've either created a user without burning a key (free account), or burned a key without creating a user (customer loses their key). Both are bad.

Serialize the whole thing under a per-app lock:

def handle_register(req, ip): app_data, app_id = resolve_app(req) if not app_data: return {"success": False, "message": "Invalid app"} with get_app_lock(app_id): users = get_app_data(app_id, "users", []) keys = get_app_data(app_id, "keys", []) blacklist = get_app_data(app_id, "blacklist", []) # Check blacklists first (IP and HWID)  if is_blacklisted(blacklist, "ip", ip): return {"success": False, "message": "IP is blacklisted"} if is_blacklisted(blacklist, "hwid", req.hwid): return {"success": False, "message": "HWID is blacklisted"} # Username collision  if any(u["username"] == req.username for u in users): return {"success": False, "message": "Username already taken"} # Constant-time key lookup. hmac.compare_digest prevents timing  # oracles — an attacker can't tell how "close" their guess was.  req_key = req.key or "" key_idx = next( (i for i, k in enumerate(keys) if hmac.compare_digest(k.get("key", ""), req_key)), None, ) if key_idx is None: return {"success": False, "message": "Invalid license key"} key_data = keys[Source:https://dev.to/__43698c3f021/building-a-license-authentication-system-from-scratch-258g

Related posts