Access control and query scoping
A warehouse belongs to exactly one account, and ownership never transfers. The owner may invite another registered account in — reading always, stock changes only when the invitation grants it, revocable at any time — and that invitation is the entire permission model: no role tiers, no per-location grants, no privilege to escalate to. Isolation is implemented as global query scopes bound to every model, so the constraint is part of the SQL rather than something a controller has to remember.
- Scopes are applied at the ORM layer and compose into every query, including relations, counts and aggregates
- They fail closed: with no authenticated user the scope resolves to a contradiction, so a query returns the empty set rather than the whole table
- Ownership columns are excluded from mass assignment on every model, so no submitted field can reassign a record to another account
- Nested routes resolve children through their parent's relation, so a mismatched pair is a 404 at route-binding time, before any controller runs
- Validation of foreign keys goes through scoped rules rather than raw table lookups, so a reference to an invisible record fails validation instead of resolving
Password storage
Passwords are hashed with bcrypt at cost factor 12, meaning 2^12 — 4096 — key-expansion rounds per verification. Bcrypt is deliberately slow and memory-touching, which is what makes offline brute force against a stolen database expensive rather than trivial. The cost factor is the tuning dial: each increment doubles the work for both a defender and an attacker.
- Passwords are hashed, never encrypted — encryption is reversible by design and this must not be
- Every hash carries its own random salt, so identical passwords produce different hashes and precomputed rainbow tables are useless
- Verification compares in constant time, so response timing does not leak how much of a hash matched
- The hash is excluded from model serialisation, so it cannot reach a response, a log or an export by accident
- Password reset tokens are single-use, time-limited and stored hashed, and using one revokes every paired device
Multi-factor authentication
MFA is available as a time-based one-time password, implementing RFC 6238 over RFC 4226 — the scheme every mainstream authenticator app speaks. The shared secret is 160 bits of CSPRNG output, base32-encoded per RFC 4648, and delivered by QR code as an otpauth:// URI. Parameters are fixed to HMAC-SHA-1, six digits and a thirty-second step, which is what those apps expect and what makes a secret work everywhere without configuration.
- Verification accepts one step either side of the current one, tolerating clock drift and a code typed as it rolls over
- Codes are compared with a constant-time function, so timing cannot be used to narrow the search
- The secret is stored as an encrypted column, so a database dump without the application key yields no enrollable secret
- Enrolment is a two-phase commit: a pending secret is not active until a generated code is submitted, so nobody can lock themselves out of an account by scanning a code and walking away
- Recovery codes are single-use, drawn from an alphabet with no ambiguous characters, consumed on redemption, and regenerable at will
- Turning MFA on or off requires the current password, and turning it on revokes every paired device
Sessions and cookies
Sessions are server-side. The cookie carries an identifier and nothing else — no user data, no permissions, no claims — so there is no client-held state to tamper with and nothing to forge. The session record itself lives in the database, which is what makes immediate server-side revocation possible at all.
- The session cookie is encrypted and authenticated with the same AES-256-CBC and HMAC-SHA-256 construction as data at rest, so a modified cookie is rejected rather than misread
- HttpOnly is set, so no script can read the cookie even if a cross-site scripting flaw were found
- SameSite=Lax is set, so the cookie is not attached to cross-site subrequests — a second, independent line against request forgery
- The Secure attribute is set whenever the deployment is served over https, and a deployment audit reports any production install where it is not
- The session identifier is regenerated on every successful authentication, which closes session-fixation attacks
- Signing out invalidates the server-side session and rotates the CSRF token, so the old identifier is inert rather than merely forgotten
- Sessions carry an idle lifetime, after which the record is no longer valid regardless of what the client still holds
- Session payloads are serialised as JSON rather than PHP, which removes the object-injection gadget-chain class of attack outright
Encryption at rest
Selected columns are stored as authenticated ciphertext. Encryption is AES-256-CBC with a random initialisation vector per write, and an HMAC-SHA-256 computed over the IV and ciphertext together — encrypt-then-MAC, which is the composition order with a security proof behind it. The key is a 256-bit application key held outside the database. There is a full worked example below.
- Encrypted: supplier email, phone and notes; movement reason and reference; unit notes; note bodies; image filenames and alt text; paired-device and print-station names, IP addresses and user agents; and the MFA secret and recovery codes
- Not encrypted: names, SKUs, location codes, barcodes, serials, quantities and foreign keys — the cipher is non-deterministic, so an encrypted column cannot appear in WHERE, LIKE, ORDER BY, an aggregate or a UNIQUE index
- That trade-off is the whole reason the list is short: encrypting an identifier would break scanning, search, sorting and every total in the app
- The MAC is verified before decryption is attempted, so tampered ciphertext is rejected rather than decrypted into something unpredictable
- The covered list is a single registry asserted against the models in both directions, so a column cannot silently gain or lose encryption
- Because the key sits outside the database, a backup taken without it is not a backup — losing the key is data loss, not downtime
- Encrypting a whole site under a key the server never holds is a separate and stronger design, still to be built, and it is on the roadmap
Request integrity and input handling
Authorisation is evaluated per record rather than per screen, because being allowed to open a page is not the same as being allowed to mutate the thing on it. Everything crossing the boundary is validated against an explicit schema before a controller sees it.
- Every state-changing request carries a synchroniser token bound to the session — the CSRF middleware is applied globally and has no exemptions
- Input is validated by form request classes that allowlist fields; anything not declared is discarded rather than passed through
- All database access goes through the ORM's parameter binding, so query values are never concatenated into SQL
- Template output is escaped by default; the handful of places emitting raw markup are server-generated QR and label SVG, and each is documented individually
- Uploaded images are validated by content-derived type, stored outside the public directory under a generated filename, and served only after the same authorisation check as their parent record
- A denied record returns 404 rather than 403, so response codes cannot be used to enumerate which sequential identifiers exist
- Authentication endpoints are rate-limited on both the account and the origin address, so neither a single target nor a single source can be hammered
What a page is allowed to do in your browser
Escaping is the first line: text you typed is never treated as markup. A content security policy is the second, sent with every response, deciding what the page may do once it has loaded — so a single escaping mistake anywhere becomes a script that does not run rather than one that does. The policy is sent by the application itself rather than by the web server in front of it, which is what makes it identical on every installation, present while the app is being developed, and checked by the test suite on every change.
- Scripts run only from this site, and only when they carry a one-time value generated for that single response — an injected script has none and never executes
- Inline scripting and dynamic code evaluation are refused outright; the one exception is the compiled decoder the barcode reader falls back to on browsers with no built-in one
- The page cannot be framed by another site, cannot load a plugin, and cannot be given a new base address — three of the ways a page is made to act against the person reading it
- A form on the page can only submit back to this site, so an injected form has nowhere to send what you type into it
- Camera, tag reading and the two wired-scanner connections are permitted to this site alone; location, microphone, payment and screen capture are switched off entirely
- Responses are never re-interpreted from their bytes, the page you came from is never disclosed to another site, and over https the browser is told to refuse plain http for a year
Paired devices and bearer tokens
A phone can be paired as a scanner without ever receiving your password. Pairing is a challenge shown on an already-authenticated screen; what the phone ends up holding is a bearer token, and bearer tokens are treated like passwords — the server stores only a digest of them.
- Tokens are 40 characters of CSPRNG output and are stored as a SHA-256 digest, so a database disclosure does not yield usable credentials
- A fast digest rather than bcrypt is correct here: the token is high-entropy random rather than human-chosen, so there is no dictionary to slow an attacker down through
- The pairing code is single-use, short-lived, and swapped for the long-lived token inside a locking transaction, so it cannot be redeemed twice concurrently
- Before pairing, the device is shown which account it is about to join and warned if that would displace another
- Changing a password, completing a password reset, or enabling MFA revokes every paired device at once
- Revocation is enforced on the next request of any live session on that device, not merely on future sign-ins
Immutability and auditability
Stock levels are a projection over an append-only ledger rather than a mutable field. That is a data-integrity decision before it is a security one, but it has a security consequence: there is no code path that can quietly change a quantity, because no such write exists.
- Every movement records what moved, from where, to where, when, by whom and why, and is never updated afterwards
- A correction is a compensating entry, so the original and the correction both remain visible
- Closing a stocktake emits ordinary adjustment movements for each discrepancy, leaving the same audit trail as any other change
- Deleting a record that carries history requires explicitly lifting a per-user guardrail first
- Every list is exportable, so the data can be taken out and checked independently of the app
- Access is recorded separately from stock and kept for a year: each sign-in and failed sign-in, each device paired or revoked, each change to who may see the warehouse, and each refused action — never a password, a code or a token, which are removed before anything is written
Transport and deployment
The application is designed to sit behind a TLS-terminating reverse proxy. Proxy headers are honoured so that the framework sees the real client address and scheme, which is what makes secure-cookie and rate-limit decisions correct rather than accidentally uniform.
- Production is served over TLS, terminated at the proxy; the session cookie's Secure attribute is derived from that scheme rather than assumed
- The browser-side protections are sent by the application rather than the web server, so they are the same on every installation and cannot be lost by putting a different web server in front
- Dotfiles are denied by the web server, and the deployment audit fails if a secret-bearing file is reachable under the public directory
- A deploy may add a missing environment setting but is forbidden from rewriting one that exists, so an operator's decision is never silently overwritten
- Application keys, database passwords and mailer credentials are on a never-write list that no deployment path may touch
- An audit command independently checks environment configuration, secret file permissions and ownership on a running server
Reading a photograph, and where that happens
An installation can be set up to read the label off a photograph, so that photographing a carton fills in a name, a part number and a barcode for you to check. Everything involved runs on the machine this application runs on. There is no cloud service behind it, no account with anybody, and no API key: the text recogniser, the model that guesses what the thing is, and the object detector are programs the operator installs and runs themselves. A photograph you take is read where your data already lives and is not sent anywhere else. The feature is optional and off until somebody sets it up, and it is marked experimental in the interface while it settles.
- The recognisers run locally as a separate service on the same machine; the application talks to it over an interface nothing outside the machine can reach
- Your browser never contacts the recogniser — the server does — so a photograph never travels to a third party by any route, and the content security policy is never widened to allow one
- Where a language model is used, it is one the operator has installed and runs locally, and it is asked about your picture on your own hardware
- A photograph taken to fill in a form is kept for hours, not days: once the product is saved it becomes that product's picture, and anything left over is swept on a schedule
- No identifier ever comes from a model. A part number, a barcode and an SKU are read from the text on the packaging and nothing else, because a plausible wrong code is worse than an empty field
- What each step produced, and how long it took, is shown to the person who asked, so a suggestion can always be traced to the thing that made it
How all of this is verified
Claims are worth nothing without something that fails when they stop being true. Every property described on this page is pinned by an automated suite of well over two thousand tests that runs on every change, including tests whose only purpose is to prove one account cannot reach another's data.
- Dedicated tenant-isolation and access-boundary tests probe model-bound routes as a stranger and require a 404
- A route-coverage test asserts the complete unauthenticated surface, so making a new endpoint public fails the suite until it is argued for in writing
- The encrypted-column registry is asserted against the model casts in both directions
- Query-count assertions bound how many queries a screen may issue, so a scoping regression that turns into a full-table read is caught as a performance failure too
- The app is reviewed against the OWASP Top 10 and the OWASP Testing Guide, and assessed against every control in Annex A of ISO/IEC 27001:2022
- Findings from those reviews are tracked and worked through; the register itself is not published, because an itemised list of current weaknesses is the one document that genuinely does help an attacker