Logins and paid memberships
The goal#
The tools site needed paid Pro memberships. We compared three approaches, reviewed the existing login system, chose to build on it in a way that can later be moved to WordPress, and created the first admin account with an editable profile and password recovery.
Part 1: storing passwords properly#
Never store a password. Store a hash: a one-way scramble. At login, hash what the person typed and compare.
| Method | Verdict |
|---|---|
| Plain text | ❌ One leak exposes every password |
| Fast hash (MD5, plain SHA-256) | ❌ Attackers can try billions of guesses per second |
| Slow, salted hash: PBKDF2, bcrypt, Argon2 | ✅ Each guess is deliberately expensive; a random salt makes identical passwords hash differently |
The old system used PBKDF2 (150,000 rounds): secure, but stored in a custom format. New accounts use PHP's built-in password_hash() (bcrypt), because it's standard and WordPress can read it, which matters for migration.
$hash = password_hash($password, PASSWORD_DEFAULT); // store this
password_verify($typed, $hash); // true / false at login
The plain password never reached the server
We computed the bcrypt hash locally and uploaded only the hash. Even the setup script never contained the password.
Part 2: login tokens#
After login, the server gives the browser a token so it doesn't need the password again. Ours look like:
base64(payload) . base64(signature)
payload = { sub, role, plan, exp, ... }
signature = HMAC-SHA256(payload, SERVER_SECRET)
- The signature proves the server issued it: change one character of the payload and the signature no longer matches.
expmakes it expire (30 days for members).- A
token_versionstored per member lets us log someone out everywhere: bump the number and every old token stops working. We do this automatically when a password changes.
Signed is not secret
Anyone can read the payload (base64 is just encoding). The signature only stops them changing it. Never put secrets in a token.
Part 3: where is "Pro" decided?#
This was the most important finding of the review.
// in the browser
if (SGAuth.isPro()) showProTool();
The browser decided Pro from data stored in the browser. Anyone comfortable with developer tools could edit that and unlock Pro features without paying. Meanwhile the AI proxy was correctly checked on the server.
The golden rule of paid features
The browser can display, but only the server can decide. Anything worth paying for must be checked on the server: either the server only delivers the Pro tool after verifying the token, or the valuable work (e.g. AI calls) happens on the server.
An honest limit
A tool that runs entirely in the browser (like a PDF tool) can be copied once it has been delivered. That's true of every website. Server-side delivery plus terms of use is the normal protection.
Part 4: payments without touching cards#
Never handle card details yourself. Stripe Checkout takes the card on Stripe's own page; your server only stores "this person is Pro until this date".
"Go Pro" ──► Stripe Checkout (card handled by Stripe)
│ webhook: "payment succeeded"
▼
your server: plan = pro, pro_until = end of period
"Manage billing" ──► Stripe Customer Portal (cancel, change card, invoices)
A webhook is Stripe calling your server when something happens (paid, renewed, cancelled). Your server must verify the webhook's signature, so nobody can fake a "paid" message.
Part 5: three ways to build memberships#
| Option | What it means | Good when |
|---|---|---|
| A. Own system | Extend the site's own login + a MySQL table + Stripe | You want members to stay on your site, full control |
| B. WordPress plugin | Members live in WordPress; tools check the WordPress login | Fastest, most battle-tested |
| C. Managed auth | Supabase / Firebase / Clerk | Many users, social logins |
We chose A, built "migration-ready", so moving to B later is a data import, not a rebuild:
- Email is the login name, which WordPress plugins expect.
- Standard bcrypt hashes, which WordPress can read.
- Billing lives in Stripe: store the customer and subscription IDs, so the same subscriptions keep working after a move.
- Few simple plans: free, pro monthly, pro yearly.
- One gate function,
sgai_member_is_pro(): a migration changes only this. - CSV export of members from day one.
Why a database, not a JSON file, for members?
Knowledge bases are written rarely and read often: files are fine. Members sign up and pay at the same time as each other; a database handles simultaneous writes, unique emails and safe updates. The hosting plan already includes MySQL/MariaDB, so it's still no third party.
Part 6: password recovery done right#
- Same answer whether or not the email exists, so the form can't be used to discover who has an account.
- The reset link holds a long random token; the database stores only its hash, so a database leak doesn't expose working links.
- Links work once and expire in an hour.
- Rate-limited per IP so it can't be used to spam.
The reset email never arrived
Everything worked except delivery. The event log said emailed: false. Sending one test directly to the email service showed the real reason: "The domain is not verified." An email service only sends from domains whose ownership you've proven through DNS records. Lessons: log outcomes (not just attempts), and when a system says "failed", reproduce the call directly to read the exact error.
Empty database settings
The first connection test failed with error 1045 (access denied). The script reading the settings file had choked on an unrelated line and produced empty values. Reading the four needed values directly fixed it. Lesson: after generating a config file, print what it contains (masking secrets) before using it.
Key takeaways#
- Store slow, salted hashes (bcrypt), never passwords.
- Signed tokens prove who issued them; they don't hide their contents.
- Paid features: server decides, browser only displays.
- Let Stripe hold the cards; verify every webhook.
- Design for migration from day one: standard formats, one gate function, exportable data.
Quick quiz#
1. Why is a fast hash like MD5 bad for passwords?
Attackers can try billions of guesses per second. Password hashes should be deliberately slow.
2. Someone edits their browser storage to say plan: "pro". What stops them using a paid feature?
Only a server-side check. If the server verifies the signed token and the database before serving the feature, the edit does nothing.
3. Why does "forgot password" answer the same for unknown emails?
So attackers can't use it to find out which emails have accounts.
Try it yourself#
Look inside a token
Sign in on a site you control, copy the token's first part (before the dot) and decode it at any base64 decoder. You'll see the payload in plain text, proving that tokens are signed, not secret.