Skip to content

Security architecture

The one-pager for a buyer’s IT reviewer: how CardOffice isolates tenants, who can do what, and what gets recorded. Every claim below is implemented, not aspirational; where something is planned, it says so.

Application layer. Every request establishes a database scope for the caller’s organisation before it can query at all, and the data layer refuses to run a statement outside one. A missed scope therefore fails loudly instead of returning everything. Operations that legitimately cross organisations, such as authentication, public verification flows, bureau work and background sweeps, are declared explicitly rather than being a default. Permission checks happen on the server for every action, never in the browser.

Database layer. PostgreSQL row-level security is enabled on every table that holds an organisation’s data. Organisation traffic runs under a database role that does not own the tables, so the policies apply to it and bind every row to the organisation of the current transaction. Bureau access is a separate role that a request must hold, not a flag it can set.

What the database layer is worth. It is a real second line of defence against an application mistake: a query that names another organisation’s record returns nothing, and a write into another organisation is refused. It is not, on its own, a defence against an attacker who can already run arbitrary SQL as the application. For self-hosted deployments, separating the database credentials used for organisation traffic from those used for service work narrows that further; your CardOffice contact can advise on the setup.

Both layers are covered by automated tests against a real database, including attempts to read and write across organisations.

Deactivating a card holder — in the portal, over SCIM, or through Google Workspace sync — does two things. The roster row changes, and any card queued for that person but not yet on a printer is cancelled, so a deactivation cannot be overtaken by a card that was already in the production queue. A job already printing is left alone deliberately: the card is physically moving through the hopper, and the agent’s own report is the honest record of what came out.

Verification takes the holder into account, not just the card. The public verifier and the /api/v1/cards/verify endpoint both refuse a card whose holder is inactive, suspended or deleted, so a deactivated person’s card stops verifying immediately, without waiting for anything downstream.

Deactivation does not currently revoke credentials the person already holds — the issued card, its wallet pass and its verifiable credential stay as they are until someone revokes the card explicitly (which does void the pass and set the VC status bit, in one transaction). That is a deliberate limit, not an oversight: directory feeds flap, and automatically voiding a wallet pass on a transient status change is its own kind of outage. Sites that need automatic downstream revocation should treat it as an integration requirement.

Object storage keys are prefixed per tenant ({companyId}/photos/…), and photo access is via short-lived signed URLs — photo bytes never transit the application tier in either direction.

Six roles, whitelist-only permission matrix (25 permissions, e.g. holders:write, photos:review, orders:produce, settings:write):

Role Scope
Super admin The bureau. Cross-tenant by design; two-factor always required
Administrator Their organisation: settings, users, billing, everything operational
Staff user Day-to-day: holders, photos, orders — no settings, no user management
Print operator Production only; cannot touch settings, billing or user management
Viewer Read-only: holders, photos, orders, templates and production status; no reports, nothing it can change
Card holder Cannot sign in at all. Photo upload runs on single-purpose emailed tokens; a compromised student mailbox yields no portal session

Role changes propagate to live sessions within 60 seconds (see revocation).

  • Passwords: scrypt (memory-hard), per-account rate limiting, 8-failure lockout, constant-work verification whether or not the account exists.
  • Two-factor: TOTP with single-use recovery codes; enforceable per organisation, always required for super admins, and the enforcement reaches live sessions, not just new sign-ins.
  • Single sign-on: OIDC (Entra ID, Google Workspace, Okta) — authorization code + PKCE + nonce, issuer/audience/signature validation against the tenant’s own discovery document, IdP-subject-first account matching scoped to the tenant. Organisations can enforce SSO, refusing passwords outright. IdP client secrets are AES-256-GCM encrypted at rest under a dedicated data key, separate from the session-signing secret, and write-only in the UI.
  • Revocation: sessions carry an epoch; deactivating a user, resetting a password or revoking access bumps it, and every live session for that account dies within 60 seconds — stateless JWTs notwithstanding.

Changes write an audit row — actor, tenant, entity, action, changed fields, IP, user agent — inside the same database transaction as the change where possible, so the log cannot disagree with the data. That covers card holders, photos (upload through the portal, the API or an emailed link, approval, rejection, recrop, deletion), orders and their items, issued cards, users, settings and two-factor changes. Not audited: background processing by the worker, which follows from an audited action, and reads. Sign-ins (including SSO, including refusals and the reason) are recorded. The log is per-tenant and queryable by the organisation’s administrator, because “who changed this and when?” is usually their privacy officer’s question, not ours.

  • Secrets at rest: IdP client secrets and wallet authentication tokens are AES-256-GCM encrypted; recovery codes, invitation and reset tokens are stored as SHA-256 hashes only.
  • Photo retention is a per-organisation setting; deletion is honoured in storage, with an off-site backup deliberately isolated from propagating deletions (ransomware posture: the copy that exists to survive deletion on the server cannot be deleted from the server).
  • Nightly encrypted-at-rest database dumps plus a versioned off-site mirror; restore is drilled, not assumed (last full drill: 12 Aug 2026 — database, photos and secrets all restored and verified).

Runs as managed SaaS on New Zealand infrastructure, or fully self-hosted on your own servers — the same codebase, provisioned by the same tooling. For jurisdictions nervous about student photos leaving the country, data residency is not a negotiation: run it where the data must live. All public traffic is TLS via a single reverse-proxy door; application services, database and object storage listen on loopback only.

  • SCIM 2.0 roster provisioning (Entra ID, Okta) is live — bearer-token authenticated, per-tenant, rate-limited, soft-deactivate only. The v1 REST API is rate-limited per key as well.
  • Content-Security-Policy: framing, <base>, plugins and form targets are enforced; the full policy runs report-only while violations are reviewed, and inline scripts are still allowed until per-request nonces land. HSTS is on (one year, not preloaded).
  • ISO 27001 / SOC 2 — not certified; this document describes controls, not attestations. No independent penetration test has been done yet.
  • Dependencies are audited on every CI run (npm audit, failing on high or critical) and Dependabot opens update pull requests weekly.
  • Vulnerability reports: /.well-known/security.txt on the app names the contact.
  • SAML — not supported; the three major IdPs are served via OIDC.

© 2026 Secure ID Ltd. All rights reserved.Documentation licence and terms