RobCo Depot
Language
Register
How your data is protected

Security, in detail

This page names the actual algorithms RobCo Depot uses, the parameters they run with, and the published specifications they come from — because a security page that will not tell you what it uses is not telling you anything. Nothing here was invented for this app.

The four properties everything else serves

Single-tenant by construction

A warehouse belongs to exactly one account. Isolation is enforced by query scopes applied to every model at the ORM level, not by checks written per screen — and they fail closed, returning nothing when no user is authenticated.

Append-only stock ledger

Stock levels are derived, never written. Each change is an immutable row carrying actor, reason and timestamp, so a correction adds a compensating entry rather than overwriting evidence of the original.

Authenticated encryption at rest

Free-text and personal columns are stored as AES-256-CBC ciphertext with an HMAC-SHA-256 tag, keyed from a 256-bit application key. A stolen database file without that key is worthless. There is a worked example further down.

Minimal attack surface

No role tiers, no public API, no third-party scripts and almost no JavaScript. Access beyond the owner is one revocable invitation rather than a permission system, and most of the vulnerability classes an application this size would normally carry are absent because the feature that would carry them does not exist.

Proven primitives, not invented ones

Every cryptographic decision here defers to a published standard and a widely reviewed implementation. These are the same primitives behind online banking, password managers and the authenticator app already on your phone — chosen precisely because they have survived decades of public attack rather than because they are clever.

Password storage
bcrypt, cost factor 12
Provos & Mazières, 1999 · OWASP Password Storage Cheat Sheet
Encryption at rest
AES-256-CBC, encrypt-then-MAC
NIST FIPS 197 · NIST SP 800-38A
Ciphertext integrity
HMAC-SHA-256
NIST FIPS 198-1 · RFC 2104
Multi-factor authentication
TOTP — HMAC-SHA-1, 6 digits, 30-second step
RFC 6238 over RFC 4226
Authenticator secret encoding
Base32, 160-bit secret
RFC 4648
Device token storage
SHA-256 digest, never the token itself
NIST FIPS 180-4
Token and key generation
Operating-system CSPRNG
getrandom(2) · BCryptGenRandom
Cross-site request forgery
Synchroniser token + SameSite=Lax
OWASP CSRF Prevention · RFC 6265bis
Transport security
TLS, terminated at the reverse proxy
RFC 8446

None of these algorithms is home-grown, and none is used in a mode of its own devising. The encryption, hashing, session and CSRF implementations are the Laravel framework's, which is to say they are the code paths exercised by a very large share of the PHP web and audited accordingly. Rolling your own cryptography is the classic way to produce something that looks secure and is not.

How each part works

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

Worked examples

Both samples below are real output from this application rather than illustrations. If you want to check any claim on this page, these are the two places you can do it directly.

What an encrypted column actually contains

Assigning a value to an encrypted attribute is ordinary code — no caller has to know the column is protected. Encryption happens as the model is written.

What the application code does
$supplier->notes = 'Damaged in transit';
$supplier->save();
What is stored in the database column
eyJpdiI6Ii81NlpsckRZZjhxWGZ4MzNOdG85RlE9PSIsInZhbHVlIjoiZXFHSmxk
L3hCVUJxOTRDSCtDb3hVQU1BTmNyWkE4UXhPaml6N0NmdDZydz0iLCJtYWMiOiJh
YzRlYzg0NGIzM2Y4OTM3ZTRkY2Y4ZWI2ODJiNWQ2YzU3NmEyMDBkMWEzZDBhNjE2
OWYzMDg2Mzk3MTRiM2JiIiwidGFnIjoiIn0=
That value, base64-decoded
{
    "iv":    "/56ZlrDYf8qXfx33Nto9FQ==",
    "value": "eqGJld/xBUBq94CH+CoxUAMANcrZA8QxOjiz7Cft6rw=",
    "mac":   "ac4ec844b33f8937e4dcf8eb682b5d6c576a200d1a3d0a6169f308639714b3bb",
    "tag":   ""
}
iv
A 128-bit initialisation vector, generated from the operating-system CSPRNG on every single write. It is what makes the same plaintext encrypt differently each time, and it is not secret — only unpredictable.
value
The AES-256-CBC ciphertext itself, keyed from the 256-bit application key. Without that key this is indistinguishable from random data.
mac
An HMAC-SHA-256 computed over the initialisation vector and the ciphertext together. It is checked before any decryption is attempted, so a modified payload is rejected outright instead of decrypting into something an attacker chose.
tag
Empty for CBC mode, where the separate MAC provides authentication. It carries the authentication tag when an AEAD cipher such as AES-256-GCM is configured instead.

Write the same string a second time and every field above changes except the structure, because the initialisation vector is drawn fresh each time. That is exactly why an encrypted column cannot be searched or indexed: two identical values are not identical bytes, and the database has no way to know they match.

What a stored password actually contains

A password is not encrypted, and that is deliberate: encryption can be reversed by whoever holds the key, and nobody — including whoever runs the server — should be able to recover your password. What is stored is a one-way hash.

A bcrypt hash, field by field
$2y$12$JqOry3tzDavAH0m.fAKj6O7L2RQAVfcFHCjMtzcEbVPw9zj5MtDwK
 └┬┘ └┬┘ └──────────┬─────────┘└───────────┬──────────────┘
  │   │             │                       │
  │   │             │                       └─ hash
  │   │             └─ salt
  │   └─ cost
  └─ algorithm
algorithm
$2y$ identifies the bcrypt variant. It is a deliberately slow, salted password-hashing function, in contrast to a fast general-purpose digest like SHA-256, which would be entirely the wrong tool here.
cost
12 is the base-two logarithm of the work factor, so verifying one password runs 4096 key-expansion rounds. Every increment doubles the cost of an offline brute-force attempt as well as of a legitimate sign-in.
salt
128 bits of random data, unique to this hash and stored alongside it. It is why two accounts with the same password have completely different hashes, and why precomputed lookup tables are of no use against them.
hash
The bcrypt output over the password and the salt. This is the only part that depends on what you typed, and there is no computation that runs it backwards.

Nothing in that string can be turned back into the password. Verifying a sign-in re-runs the same computation with the stored salt and compares the results in constant time, which is why a correct password can be confirmed while a stored one can never be revealed — not by us, not by a database thief, and not by a court order.

What this app deliberately does not do

Some things are absent on purpose. Each is a decision, and each removes a class of vulnerability by removing the feature that would have carried it.

  • No role tiers and no permission levels — a warehouse has exactly one owner, and the only grant that exists is a whole-warehouse invitation the owner can take back at any time
  • No public API and no personal access tokens, so there is no long-lived credential outside the pairing flow
  • No analytics, advertising or third-party scripts, so nothing loads into your browser from a domain that is not this one
  • Almost no JavaScript, and every JavaScript-driven feature degrades to something usable without it
  • No support access to your data: there is no administrative back office that can read a warehouse on your behalf
  • No recovery for the owner-held site key once it exists — that is the unavoidable cost of the server genuinely not holding it

Reporting a vulnerability

If you believe you have found a security issue, please report it privately rather than publicly, with enough detail to reproduce it — ideally a request sequence and the behaviour you expected instead. Reports are read, and good-faith research is welcome.

support@robco.nl

For what is collected, why it is retained and for how long, see the privacy statement