Security and privacy
What the product does to protect the record, and what it deliberately never stores.
This page describes the controls a buyer inherits. It is not a substitute for hardening the server itself, keeping PHP updated, and using HTTPS.
Accounts and sessions
- Passwords are hashed, never stored or logged in a readable form, and checked against a policy on every change.
- Two-factor authentication is required for internal accounts. Secrets are encrypted with
APP_KEY; recovery codes are stored as hashes and each works once. - Sessions expire on inactivity and again at an absolute age, and are stored server-side so that disabling an account or changing its role revokes them immediately.
- Sign-in, two-factor, activation, password reset and public verification are all rate limited, per account and per address.
- Activation and reset links are one-time and are stored as hashes, so a copy of the database does not yield a working link.
Authorisation
- Every route states the role and permission it needs, and the check runs on the server. Hiding a button is treated as convenience, never as protection.
- A verifier's access is scoped to its own assignments, checked when the record loads, so changing an identifier in the address bar returns 403.
- The administration area additionally requires the Admin or Manager role, so a verifier permission cannot reach it.
- Permissions come from the role only. Per-account grants are removed by the seed, so no account can quietly exceed its role.
The application surface
- Every database query is a prepared statement with bound values.
- Every form carries a one-time token, and every state-changing request is a POST that validates it.
- Template output is escaped by default, and a content security policy restricts what a page may load.
- Requests arriving under an unexpected host name are refused, which stops host header injection into generated links.
- Uploads are checked by content rather than by file name, with size, dimension and pixel
limits, and archive expansion limits for
.xlsx. - Generated PDFs, uploaded backgrounds and branding assets are stored outside the served folder and streamed through an authorising route, with random file names.
External API credentials
- Bearer tokens are generated with high entropy, stored only as SHA-256 hashes, displayed once, and can expire or be revoked independently.
- Issue, read and revoke are separate scopes. Every lookup includes the owning client, so one integration cannot discover another integration's records.
- Write requests require an idempotency key. The method, path and canonical body are bound to it in the same transaction as the certificate change, so simultaneous retries create one result and changed content receives a conflict.
- The API accepts only the fields in
/openapi.yaml. In particular, a national identity or registration number is rejected as an unknown property.
What the public can see
A verification page shows the participant name, the course, the certificate number, the issue date, the expiry date if there is one, the issuing organisation, the approving verifier organisation, and the certificate document.
It does not show, and no public route exposes, an email address, a telephone number, the participant's employer or job title, any internal identifier, or any account information. There is no public listing: a visitor can check a certificate they already have an identifier for, and cannot browse or enumerate the register.
- Only verified, non-revoked records expose certificate details. An unarchived revoked record shows only a generic revocation notice, with no participant facts, dates, reason or PDF. Pending, rejected, deleted and archived revoked records remain not found.
- Direct links use 48 characters of randomness, which cannot be derived from a certificate number.
- Search engines are kept off certificate pages while
PUBLIC_CERTIFICATE_INDEXINGisfalse.
What is never stored
- National identity or registration numbers. There is no column for one, in any table, and none may be put into an import file, an export, a log or certificate metadata. This is a deliberate design constraint, not an oversight.
- Payment data. The product takes no payments and holds no card data.
- Secrets in the audit log. Passwords, tokens, one-time codes, recovery codes, session and form tokens, cookies and authorisation headers are excluded from audit metadata by construction.
The audit trail
Sensitive actions are recorded append-only: who acted, what changed from what to what, when, from which address and with which browser. Certificate status changes, verifier decisions, role changes, branding and email settings changes are all included. Nothing in the interface deletes an audit entry; the only removal path is the archiving job, which exports a completed calendar year to a compressed file first and then deletes exactly the rows it exported.
Reading the log after an upgrade
Each row keeps the action name that was current when it was written, because rewriting stored history would destroy the record. The filter groups an event under the name in use today and counts the older names into it, so one event is one option in the list and selecting it returns the older rows as well. The stored name is printed under every row, so nothing is hidden by the grouping.
Your obligations as the operator
You are the controller of the personal data in your installation. The product gives you the tools; the decisions remain yours.
- Serve the site over HTTPS, and keep
FORCE_HTTPSandSESSION_SECURE_COOKIEenabled. - Tell participants that a verifiable record of their certificate will be published, and what it will contain.
- Decide and document how long you keep records, and who may issue and revoke.
- Protect backups as strongly as the live database: a dump contains everything the database contains.
- Remove access promptly when someone leaves, by disabling the account rather than sharing it onward.
Reporting a vulnerability. The package includes SECURITY.md at
its root with the controls in place and the hardening checklist. If you find a security
problem in the product, report it by email rather than in a public forum, and include the
version from VERSION.