Security architecture

The page we would ask a vendor for.

Not a trust badge and not a list of adjectives. The mechanisms Pental is built on, described precisely enough that you could go and verify every one of them.

180RLS policies
47tables with RLS
98freshness checks
2independent signatures
9encrypted secret columns
0copies of your data
ISOLATION

One firm, one database. The separation is physical.

separation: physical, not logicalshared tables: noneregion: yours, at creation

Every firm runs on its own Postgres project, in its own account, in a region it picks. No shared table holds several firms’ rows next to each other, so there is no tenant filter that could be written wrong.

The usual arrangement is one database with a tenant column, where the entire separation rests on every query remembering to filter. Here there is nothing else in the database to reach.

AUTHORISATION

Access is decided by Postgres, not by application code.

policies: 180tables: 47enforced by: Postgres, per query

180 row-level security policies across 47 tables, evaluated on every query. Application code cannot wave them through and neither can a bug in it. A request that fails a policy gets zero rows, not an error describing what it was not allowed to see.

Roles sit in the same place: internal roles by seniority, a sales role, and client accounts locked to their own organisation. A client reads its own record, only its own assessments, and only the findings you release. None of that is enforced by hiding a button.

FRESHNESS

A stolen token, on its own, reads nothing.

freshness checks: 98 sitesenrolment: before the portal opensnew factor: needs an existing one

Most platforms treat MFA as a property of the account: you enrolled once, so you are trusted. The right question is whether this session has recently proved possession of a second factor.

Every data-access policy calls mfa_is_fresh(), at 98 sites in the schema, so lifting a token off a machine is not enough to read a finding. Enrolment is a gate on first use rather than a setting, and binding a new factor needs control of an existing one, so a hijacked session cannot enrol a device of its own.

SERVER CALLS

Two signatures, held by two different parties.

signatures: two, held separatelybound to: one operation, one tenantreplay: blocked by a nonce storeclock skew: bounded both ways

When our back end calls into your database it cannot simply present a secret. The call carries an envelope signed twice with Ed25519, once by the application and once by an independent cosigner under separate administration. Your database holds both public keys and verifies both.

A call signed with only one of the two keys does not validate, so there is no single machine an attacker can own that produces a valid instruction to your database.

The envelope is bound to one operation and one tenant, carries a nonce checked against a store with a unique constraint, and expires. Expiry is bounded in both directions, rejecting anything stale and anything dated more than ten minutes ahead, so a forged clock buys nothing.

LEAST PRIVILEGE

The most sensitive routines refuse the server entirely.

explicit revokes: 117definer functions: 240pinned search paths: 102MFA reset: needs your own admin

Reads of your data and administrative mutations are gated behind a real user session, so they cannot be invoked with server credentials at all. Even with full control of our infrastructure those routines still refuse, because what they require is a signed-in person in your portal.

Grants are explicit rather than left at Postgres defaults: every privileged function is revoked from PUBLIC, anonymous and authenticated callers, then granted back only where needed. Definer functions pin their search path, so none can be redirected by an object planted in an earlier schema.

SECRETS

Encrypted on write, inside your database, with a key we never see.

encrypted columns: 9key location: your databaseplaintext after write: nulled

SMTP passwords, AI keys, Stripe keys and integration tokens are encrypted by a trigger the moment they are written, using a key generated inside that same database. The plaintext column is nulled, so only ciphertext is at rest, and decryption happens in memory only to carry out the action you switched on.

Nine columns are covered, and the install verifies every one of them on each run rather than assuming.

CREDENTIALS

Sign-in built the way the guidance actually says to build it.

default: passwordless, then a second factorsign-in link: URL fragmenthashing: bcrypt 12, SHA-256 pre-hashreset tokens: hashed, single use

Passwordless by default: an emailed six-digit code, then a second factor. The sign-in link travels in the URL fragment, which browsers never transmit to a server, so a mail scanner that pre-opens links cannot consume it and cost the recipient their code. It stays out of server logs and referrer headers too.

Where passwords are enabled, hashes are bcrypt at cost 12 with a SHA-256 pre-hash, because bcrypt truncates at 72 bytes and a long passphrase should not quietly become a short one. Policy follows NCSC: a 12-character floor, no composition rules, no forced expiry, and rejection of the padded common roots people actually type.

Setup and reset tokens are stored only as a SHA-256 hash, single use and expiring, so reading the database cannot be replayed into an account takeover.

ENUMERATION

A wrong address and a real one behave identically.

throttle key: hash of the addressrejection: one generic answertiming: constant worklockout: throttle instead

Throttling is keyed on a hash of the address submitted, not on the account. If only real accounts could be throttled, an attacker learns which addresses exist from which ones eventually answer "too many attempts". Hashing also stops the table becoming a readable list of who has been typing at your login form.

Rejections are undifferentiated across unknown, inactive, no-password and wrong-password, with constant hashing work on every one so timing gives nothing away. It throttles rather than locking out, because a hard lockout hands an attacker a denial of service against a named user.

DESTRUCTION

The dangerous operations require a hardware key over the payload.

challenge: hash of the operationapproval: bound to one payloadswap after approval: not possible

Where an operation is genuinely destructive it can require a physical security key, and the detail that matters is what the key signs: a hash of the actual operation and its parameters, not a bare approval prompt. What was approved cannot be swapped afterwards.

BOUNDARIES

The edges nobody puts on a marketing page.

file storage: private bucketssessions: revocable per deviceAI endpoints: private ranges blockedrate limits: in your database

Storage buckets holding avatars and assessment files are private, not public with an unguessable path. Sessions are per-device rows you can list and revoke, with a configurable timeout and stale sweep. Self-hosted AI endpoints are filtered against private address ranges, so a URL field cannot become a request-forgery primitive against your own network.

The marketing site sends no framework banner and ships no browser source maps. The contact form is behind reCAPTCHA. Rate limits are counted in your own database, so they keep working independently of us.


Questions this page did not cover

Send them. Technical questions get a technical answer from someone who wrote the code.

Ask usStart free trial