Home›Certora›Manual
Certora · Manual

Certora Manual

Certora — User Manual

Certificates issued from templates, with a public verification page. Version 4.1.3 [src: app/controllers/api.php:23].

About this manual

Every statement here was written by reading Certora's own source, and each one carries the file it came from in a bracketed src marker. Paths are relative to the folder this docs directory sits in. If a sentence disagrees with the application, the application is right and this manual has a bug — the markers exist so you can check rather than take our word for it.

Nothing is described that the code does not do. Where something is deliberately limited, that is said plainly rather than left out.

⚠️ What a verification code is — and what it is not

This is the most important thing in the product, and the source states it without softening [src: app/src/Cert.php:10]:

Verification codes are plain lookup tokens. A code is random alphanumeric text that is unique in the certificates table — nothing more. It is not a signature, an HMAC, or any cryptographic key: verification is a database lookup of the code, never a signature check. Certora issues no keys, signs nothing, and has zero crypto surface.

What that means in practice, stated plainly because a certificate product invites the opposite assumption:

A verified code proves that this installation issued that certificate and has not revoked it. It does not prove the certificate document in front of you is unaltered, because nothing is signed. Somebody could reprint the face with different wording and the code would still verify — what verification returns is your record, not their paper.

Used the right way round, that is exactly what you want: the verifier reads the truth from your database rather than trusting the document. Just do not describe it as a digital signature, because it is not one.

Verification codes

Twelve characters from an alphabet of uppercase letters and digits with the ambiguous ones removed — no 0/O, no 1/I/L — so a printed code is easy to read back and type in [src: app/src/Cert.php:26].

That gives roughly 7.8 × 10¹⁷ possibilities, which the source notes makes collisions astronomically unlikely [src: app/src/Cert.php:30].

Uniqueness is guaranteed rather than assumed. Generation retries against a database check, and after a number of attempts it lengthens the code rather than looping forever [src: app/src/Cert.php:82].

A typed code is forgiving on the way in. Spaces, dashes and anything outside the alphabet are stripped and the result upper-cased, so abcd-1234-wxyz and ABCD 1234 WXYZ both resolve to the stored form [src: app/src/Cert.php:103]. Display groups it back into fours for printing [src: app/src/Cert.php:115].

The certificate PDF

The signatory title and the "Issued … Valid until …" line print at 10 pt [src: app/src/PdfCert.php:315]. Before 3.1.4 a number-formatting fault set both at 1 pt, so a certificate's own dates and the signatory's title were specks on the paper.

When a public verify address is set in Settings, the certificate carries its verify link as a QR code, bottom right, one inch square [src: app/src/PdfCert.php:193], so an employer can scan rather than type the code. A revoked certificate carries none [src: app/controllers/app.php:614]. The encoder is the one Assetora uses, and the suite reads every symbol back with an independent decoder. If a footer line is too long to sit beside the code even at 7 pt, the code is left off and the text keeps its size [src: app/src/PdfCert.php:163].

The public verification page

No sign-in is required [src: app/controllers/app.php:865]. Three things protect it, and one thing limits what it reveals:

  • A per-address fixed-window throttle, computed as a pure function so it can be tested without a clock or a database [src: app/src/Cert.php:126].
  • A honeypot field [src: app/controllers/app.php:866].
  • An unknown code returns 404 and never reveals whether a code was merely mistyped [src: app/controllers/app.php:867].
  • Only the certificate face is shown — recipient, course, template, issuer, issue date, validity. No email address, no administrative data [src: app/controllers/app.php:868].

Add to LinkedIn, and counting lookups (3.2)

A valid certificate's page shows Add to LinkedIn profile. The button goes through /verify/{code}/linkedin, which counts the press and sends the person to LinkedIn's Add-to-profile form filled in with the certificate's name (its course, else its title), the issue and expiry month, the code and the verify link [src: app/controllers/share.php, cr_linkedin_url()]. With a LinkedIn company page ID in Settings the certification links to your page; without it your organization's name goes as text. A revoked or expired certificate is not offered.

Every lookup on this page and on GET /api/verify/{code} goes through one function that counts it on the certificate, or remembers the code when it matched nothing [src: app/controllers/share.php, cr_verify_lookup()]. No IP address is kept with a count. A code that matched nothing is kept 90 days, and only when it is shaped like a code (8–16 characters after normalizing).

Verifications (3.2)

Verifications in the sidebar shows lookups by template, the most-checked certificates and the codes that matched nothing [src: app/controllers/share.php, cr_verification_report()]. A missed code looked up three or more times that is within two characters (Levenshtein distance) of an issued code is flagged: someone may be trying variations of a genuine certificate, or holding a copy with the code changed. It is worth a look, not proof of anything. Each certificate's record shows its own count and the last lookup.

Open Badges (3.2)

Off by default. Switched on in Settings → Sharing with a contact email (Open Badges 2.0 puts one in the issuer profile) and a public verify URL (badge addresses are built from it) [src: app/controllers/share.php, settings_sharing_save()]. Then:

  • /badges/issuer.json — the issuer profile;
  • /badges/templates/{id}.json and .svg — one badge class and image per template, kept for a template deleted since from what was issued;
  • /badges/{code}.json — one assertion per certificate with a recipient email: hosted verification, the email hashed with a salt, issuedOn, and expires at the end of the expiry day (a certificate is valid through that day). A revoked certificate answers 410 with "revoked": true [src: app/controllers/share.php, cr_assertion_doc()].

The badge image is drawn by the app as SVG (the organization's name, the template's name, your accent color). Badges are hosted, not "baked" into the image.

The recipient's page (3.2)

At /my a person types their email address. When that address holds certificates and an SMTP server is set, a private link is emailed to it; the page answers the same whether or not anything was found, so it cannot be used to learn who was certified [src: app/controllers/share.php, my_request()]. One email per address per ten minutes; ten requests per ten minutes from one network address. From a certificate's record the team can also make the link (shown once). The link (rcp_ + 40 hex; only its SHA-256 is stored, and redacted from backups) works for 30 days and lists every certificate issued to that address, with the PDF, the verify page and Add to LinkedIn. Erasing the recipient deletes their links.

Status: revoked beats expired beats valid

One function decides the displayed status, and every surface derives from it — the public verify page, the register, the dashboard and the CSV export [src: app/src/Cert.php:215].

The precedence is explicit: revoked wins over expired, which wins over valid [src: app/src/Cert.php:214].

A certificate is valid through its expiry date [src: app/src/Cert.php:203] — expired means today is past it, not on it. A certificate with no expiry never expires [src: app/src/Cert.php:208].

Month arithmetic is clamped

An expiry of "issued plus one month" from 31 January lands on 28 February, never rolling silently into March — which is what PHP's native month addition would do [src: app/src/Cert.php:186].

The source notes this is the same clamp rule already shipped in two sibling products [src: app/src/Cert.php:188], which is the right way to handle a trap that has caught somebody before.

Templates and merge tokens

A template body may contain five merge tokens: recipient, course, date, issuer and code [src: app/src/Cert.php:20], substituted at issue [src: app/src/Cert.php:43]. The tokens actually used in a given template can be listed [src: app/src/Cert.php:56].

Certificates are landscape or portrait, with anything unrecognized falling back to landscape rather than failing [src: app/src/Cert.php:226].

Users and roles

Three roles: viewer, registrar, admin [src: app/src/Auth.php:32].

A registrar is "the job this product exists for" — loading recipients, running issue batches, revoking and reinstating certificates — and cannot edit templates or settings [src: app/src/Auth.php:15]. A viewer is the rung for an auditor, or for whoever fields "is this real?" calls [src: app/src/Auth.php:14].

Why the middle role is not called "issuer"

A small naming decision with a good reason behind it [src: app/src/Auth.php:19]: the issuer setting already means the organization printed on the certificate face, and two different meanings of one word on one screen is how a permission gets granted by accident.

Why templates are admin-only

Again a specific reason rather than a generic one [src: app/src/Auth.php:23]: a template owns the title, the signatory and the validity period — what the certificate claims and how long it stands. Editing it changes what every future certificate asserts on somebody else's behalf, which is an owner's decision.

Certificates already issued are untouched either way, because the face and the expiry are snapshotted at issue [src: app/src/Auth.php:26]. Changing a template does not rewrite history.

The audit trail is granted to a viewer explicitly — audit.view is named in the viewer's list rather than arriving through the wildcard [src: app/src/Auth.php:32].

The API and agent access

Six tools are exposed to an agent [src: app/controllers/v3.php:70]: listing templates, template detail, listing certificates, an expiring report and verifying a certificate on the read side; issuing certificates on the write side.

The agent endpoint reports the same version as the rest of the product [src: app/controllers/v3.php:69].

What Certora does not do

It does not sign anything [src: app/src/Cert.php:10]. There is no key, no HMAC and no cryptographic surface at all.

It does not prove a document is unaltered. It proves what your register says about a code.

It does not tell a stranger whether a code was mistyped [src: app/controllers/app.php:867].

It does not expose anything beyond the certificate face on the public page [src: app/controllers/app.php:868]. A badge assertion carries the recipient's email only hashed.

It does not know whether a recipient saved the certification on LinkedIn. It counts presses of the button.

It does not rewrite issued certificates when a template changes [src: app/src/Auth.php:26].

It does not roll a month-end expiry into the next month [src: app/src/Cert.php:186].

Data requests: what an erase removes and what it keeps (4.1.1)

Data requests (admin) exports everything held about a recipient, or anonymizes them. The email goes whenever you ask; the name only once every certificate for that person is expired or revoked, because erasing it from a valid certificate would leave a genuine credential that no longer verifies. Anonymizing replaces the name and email on the recipient and on their certificates, the name inside each certificate's title and printed text, and the error text of a failed delivery, and removes their own links. The certificates stay on the register: they are the record that the training happened.

Kept: the audit trail's line that this recipient was added, which carries their name, because the audit trail cannot be edited.

Looking after your install (from 4.0)

Updates from inside Certora

Updates in the menu opens the Updates page (/updates) [src: app/views/updates.php]. Paste your license key once and press Save key: either key works, the one in your Lemon Squeezy receipt or the one on your order page (it starts with OWX-). Check for updates asks the store for the newest release your license covers; Certora never checks by itself.

When a new version is ready, Back up and update to that version backs up your data and the current version, installs the new one and checks it. If the update is interrupted, or the new version's database step or that check fails, the previous version is put back by itself. Certora is unavailable for about a minute while this runs. The update replaces Certora's files, including any you edited, except .htaccess, .user.ini and robots.txt: an edited copy of those is kept, with the new version's copy beside it as .new to compare and merge. The Health page lists any file that differs from the version installed, so you can see that before you update. A copy of the previous version (data/updates/code-<version>/) and a backup of your data from just before the update stay in data/updates/ [src: app/src/Updater.php]. If Certora shows an error page after an update anyway, that is your way back: copy the files from code-<version>/ over the app's files, then restore the backup on the backup page. Updating from the page needs PHP's sodium and Phar extensions; if one is missing, the Updates and Health pages say so and updates stay by hand. Where Certora is hosted for you, the host updates it and the page says so.

Reaching 4.0 from a 3.x version is the last update done by hand: replace the files as before (config.php and data/ stay, and the database migrates itself on the next page load). From 4.0 on, the Updates page does it.

Starting with sample data

The installer offers Start with sample data: certificate templates, recipients and issued certificates to try things on. You can remove it in one click. [src: app/install/index.php:131]. It is ticked by default; untick it to start empty. The sample is added when you open the first page after the installer.

When you are done with it, press the remove button in the sample banner. It asks "Remove the sample templates, recipients and certificates? Your own records stay." Only the sample goes: your own records stay, and a sample record that one of your own records uses is kept and named in the report [src: app/src/SampleData.php].

Backups by themselves, and a copy somewhere else

On the Backup & restore page (/restore), Back up once a day by itself is on by default; untick it to stop [src: app/views/restore.php:84]. The first visit at least 24 hours after the last backup starts the next one, after the page has loaded, so no cron job is needed. The newest 14 stay in data/backups/, with credentials left out of them (a restore keeps the live values for those). Back up now takes one straight away.

To keep a copy somewhere else as well, fill in an S3-compatible bucket (Backblaze B2, Wasabi, AWS S3 or your own MinIO): the bucket address (an https endpoint), the region, the bucket name, an optional folder, and the access key ID and secret key. Leave the address empty to keep backups on this server only. The copy goes only to the bucket you set; nothing is sent to Ownware. Once a week Certora reads the newest backup back through the restore's own dry run and keeps the result for the Health page [src: app/src/BackupTargets.php].

On Nginx: keep data/ private

On Apache, the .htaccess that ships with Certora keeps data/ (your backups included), src/, config.php and the other private files off the web. Nginx does not read .htaccess, so add these three blocks to your server block, above the block that passes .php files to PHP (Nginx uses the first one that matches):

location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
location ~ ^/views/.+\.php$ { deny all; }
location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

Then open /config.php in a browser: it should answer 403. Only the template files under views/ are closed, not the folder: the app's own saved-view addresses start with /views/ too, and they must reach it.

A complete server block for a new site (PHP-FPM 8.3; change the name, the folder and the socket to yours):

server {
    listen 80;
    server_name example.com;
    root /var/www/certora/app;
    index index.php;

    location ~ ^/(src|data|bin|tests|controllers|deploy|cron)/ { deny all; }
    location ~ ^/views/.+\.php$ { deny all; }
    location ~ (^|/)(config\.php|\.demo-mode)$|\.sqlite$ { deny all; }

    location = /install/ { rewrite ^ /install/index.php last; }
    location / { try_files $uri /index.php?$query_string; }

    location ~ \.php$ {
        try_files $uri =404;
        fastcgi_pass unix:/run/php/php8.3-fpm.sock;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    }
}

The Health page

Health in the menu (/health) shows, one line each, whether the install is looking after itself: the PHP version and extensions, disk space, whether data/ is writable, email, the age of the last backup, the offsite copy, the weekly check, whether an update is waiting, and any file that differs from the version installed. Each line says what to do when it is not fine. The page only reads: it never changes anything and never contacts anything [src: app/src/Health.php].

Connect it to other tools (Zapier, Make, n8n)

Certora sends a webhook the moment something happens and accepts calls to its API, so Zapier, Make and n8n can connect to it without a plugin. The steps, the events and example recipes are in API.md, in the section "Connect it to Zapier, Make or n8n"; the API page of Certora on the store shows the same file [src: app/API.md].

New in 4.1.2: sign-in limits

New in 4.1.2 (security update): Two-step codes are now limited (10 wrong per person or per address in 10 minutes), and wrong passwords count per account as well as per address. An installed app whose database does not answer keeps its web installer closed instead of showing the install form. On Ownware Cloud, opening the app from the console starts a fresh session.

Wrong passwords are counted per address and, since 4.1.2, per account too (the email typed, in any case, stored only as a hash). After 10 wrong passwords in 10 minutes the sign-in at /login answers "Too many attempts — wait a few minutes and try again." for that address, and for that account from any address. A right password clears both counts [src: app/src/LoginRate.php:40].

Wrong two-step codes (for people who turned two-step sign-in on under /security) are counted per person and per address. After 10 wrong codes in 10 minutes the code step answers "Too many wrong codes. Wait ten minutes, then sign in again. A recovery code works in place of the code." even for the right code, and the half-finished sign-in is dropped, so the password is asked again [src: app/src/LoginRate.php:91]. The right code clears that person's count; an address's count runs out ten minutes after its wrong codes. Before 4.1.2 the code step had no limit.

If you are locked out: wait ten minutes, then sign in again with your password and a current code, or one of your recovery codes (each works once). If you lost both the authenticator and the recovery codes, whoever runs the server can turn two-step sign-in off for you in Certora's database (by default the SQLite file in data/, or your MySQL database): UPDATE users SET totp_secret = '' WHERE email = 'you@example.com'; Then sign in with your password and turn it on again under /security.

On Ownware Cloud, opening Certora from the console starts a fresh session [src: app/index.php:61].

The web installer stays closed when the database does not answer: with config.php in place, /install now answers 503 "This app is already installed (config.php exists), but its database is not answering, so the installer stays closed." and writes nothing until the database answers again. Before 4.1.2 a database outage (or a missing SQLite file) showed the install form there, and whoever found it could set up a new administrator. To install again from scratch, remove config.php first [src: app/install/index.php:21].

New in 4.1.3: security update

New in 4.1.3 (security update): no other site can put Certora's pages in a frame; the /mcp address refuses requests from other sites' pages; company sign-in is stricter; and the audit CSV treats one more first character as text. Nothing else changed: no feature, screen, setting or database table, and your data, config.php and data/ are not touched.

No other site can frame these pages. Every page now answers Content-Security-Policy: frame-ancestors 'self' and X-Frame-Options: SAMEORIGIN, so a page on another site cannot show Certora in a hidden frame and trick a signed-in person into pressing a button [src: app/index.php:4]. The installer answers frame-ancestors 'none' and X-Frame-Options: DENY [src: app/install/index.php:4]. If you had Certora framed inside another site of your own, that frame now stays empty; link to it instead.

/mcp refuses other sites. A request whose Origin header names another site gets 403 before anything is read. A desktop or server client sends no Origin and is served as before, and so is a page at Certora's own address [src: app/src/Mcp.php:272].

Single sign-on. The sign-in now uses PKCE (S256), so an intercepted sign-in code is of no use on its own; a sign-in that comes back to a different browser session is asked to start again [src: app/src/Oidc.php:228]. With Google as the provider, a company address needs a Google Workspace account of that same domain (the token's hd claim), and Google must report the address as verified, so a personal Google account made with a work address no longer gets in [src: app/src/Oidc.php:215]. Gmail addresses work as before, other providers are unchanged, and password sign-in is not affected.

The audit CSV. A cell that starts with a carriage return now opens in a spreadsheet as text, as cells starting with = + - @ or TAB already did [src: app/src/AuditLog.php:138].

Docker. The Dockerfile now installs PHP's zip extension, so a Docker install can make the backup with files [src: app/Dockerfile:7].

Also changed, with no effect on a running install: the corner card on a public demo offers only the purchase, and API.md no longer shows the config example of a product we retired.

← Back to Certora · Quickstart · API · Test run