For developers

Self-host it in an afternoon.

PHP-based, no database server, no credentials file. Everything you need to evaluate, run, and harden it.

Requirements

  • PHP 8.1+ with the pdo_sqlite extension (bundled with most PHP builds).
  • Any web server that runs PHP — Apache, Nginx, or PHP's built-in server for local testing.
  • No MySQL, no Composer, no build step required for the demo.

Quick start

terminal
# 1. get the code
git clone https://github.com/MohammedNasrallah/passnumber-demo.git
cd passnumber-demo

# 2. run it (creates the SQLite file on first request)
php -S 127.0.0.1:8000 -t app

# 3. open the app
# http://127.0.0.1:8000/signup.php — create an account (Classic or Sequence)
# http://127.0.0.1:8000/login.php  — log in

On shared hosting, upload the app/ folder (e.g. to public_html/app). Its data/ folder must be writable by PHP so the SQLite file can be created; the schema creates and upgrades itself on first request.

Data model

A single table. Note what is not here: no plaintext passnumber, no symbol choices, no positions.

schema.sql
CREATE TABLE users (
  id           INTEGER PRIMARY KEY,
  username     TEXT NOT NULL UNIQUE,
  salt         TEXT NOT NULL,     -- per-account random salt
  secret_hash  TEXT NOT NULL,     -- versioned PBKDF2-SHA256 of the canonical secret
  hash_version INTEGER,           -- upgraded automatically at login
  login_scheme TEXT,              -- 'classic' | 'sequence'
  neglect_mask TEXT,              -- classic: which rows are decoys (non-secret)
  fs_cats      TEXT,              -- sequence: ordered category names (non-secret)
  fs_pattern   INTEGER,           -- sequence: which input slots are real
  n_rows       INTEGER, n_cols INTEGER,
  failed       INTEGER DEFAULT 0, lock_until INTEGER DEFAULT 0
);
-- NOT here, by design: your symbols, their positions, any plaintext.

Hashing

Registration derives one canonical token from the user's choices (each row encoded independently so the secret is collision-free), then stores only its salted hash:

store.php
// labelled tokens in fixed order — collision-free by construction
$canonical = 'r1=🐶|r2=∅|r3=🚗|r4=🎾';   // sequence: 'seq1=animals:🐱|…'
$hash = 'v3$'.hash_pbkdf2('sha256', $canonical, $salt.$pepper, 310000);

// login rebuilds the canonical from the shuffled board + typed digits,
// hashes ONE candidate (deterministic), and compares in constant time
if (hash_equals($stored, $rebuilt)) { /* ok */ }

Security checklist

Before you put this in front of real users:

  • ☐ Serve everything over HTTPS; redirect HTTP.
  • ☐ Add security headers — CSP, HSTS, X-Frame-Options, X-Content-Type-Options.
  • ☐ Add IP-level rate limiting in front of the app, not just per-account lockout.
  • ☐ Configure SMTP so verification, recovery and alert emails deliver — the flows themselves ship in the app.
  • ☐ Turn on audit logging and monitoring of failed attempts.
  • ☐ Use a large enough grid for your risk level.
  • ☐ Get a professional security review.

Limits & compatibility

The app ships two login methods — Classic (row-bound) and Sequence (3 icons in order) — on one engine; an account is created on one method and stays on it. The demo is plain PHP — there is no Laravel dependency. If your marketing or internal docs mention a framework, reconcile that with what actually ships. The demo targets PHP 8.1+ and is not tested against PHP 7. SQLite is used for zero-config portability; swapping to MySQL/Postgres is a straightforward change to the data layer but is left to the implementer.

Reminder. This is a reference implementation of a method. Treat it as a starting point you harden, not a finished product you deploy as-is.