Section 10

Security Architecture

Clerkify holds privileged case information for advocates. Security is a product requirement, not a hardening pass. This section includes one documented disagreement with the original brief.

Threat model

Controls are only meaningful against named threats. In rough order of likelihood:

ThreatLikelihoodImpactPrimary control
Credential leak via repo or laptopHighCriticalSecret manager, scanning in CI, short-lived tokens
Cross-tenant data accessMediumCriticalPostgres RLS + repository scoping (Tenancy)
API abuse by a scraped clientHighMediumAttestation, signing, rate limits, quotas
Account takeoverMediumHighOTP rate limiting, device binding, optional passkeys
Stolen or lost deviceMediumMediumShort sessions, remote revocation, biometric unlock
Insider access to production dataLowCriticalScoped credentials, access logging, no shared admin user
Network interceptionLowHighTLS 1.3, HSTS, certificate pinning in apps

Note the top row. Every one of the old system's real security failures was a credential-handling failure, not a cryptographic one. Effort should follow the threat model, and the threat model says: secrets and authorisation first.

On encrypting API payloads

A disagreement, stated plainly

The brief asks for encrypted request and response payloads with an expiry, to stop people misusing the API. The goal is right and important. Payload encryption is not how it is achieved, and treating it as the control creates false confidence — which is worse than not having it.

What it does not do

  • Confidentiality in transit is already solved. TLS 1.3 encrypts everything. Encrypting again inside TLS adds nothing against a network attacker.
  • The key cannot be kept secret. Any key shipped inside a Flutter app is extractable — jadx, frida, or a memory dump gets it in under an hour. A determined attacker recovers it once and then encrypts payloads exactly as the real app does.
  • It does not authenticate anyone. Encryption answers "can this be read", never "who is calling". Only authentication and authorisation answer that.
  • It makes real security work harder. Opaque payloads mean a WAF cannot inspect them, logs are unreadable during an incident, and debugging a production issue requires a decryption step.

Honest accounting: it does raise the bar against casual scrapers, and against someone pointing Burp at the app for ten minutes. That is real but modest, and it is obfuscation. It belongs in the same mental category as minification.

What actually achieves the goal

Taking the requirement seriously — "stop people misusing our API" — here is the design that delivers it. Note that expiry, the part of the original request that is genuinely load-bearing, is implemented properly as replay protection.

ControlStopsCost
Short-lived access tokens — 15 min, rotating refresh, revocableLong-term use of a stolen tokenLow
HMAC request signing — over method, path, body hash, timestamp, nonceTampering, and this is where expiry livesLow
Nonce cache + timestamp window — 60s window, nonce single-use in RedisReplay of a captured requestLow
App attestation — Play Integrity, App AttestCalls from anything that is not the genuine app. The actual answer to the brief's concern.Medium
Certificate pinningCasual traffic inspectionLow, but plan for rotation
Rate limits + quotas — per user, org, IP, endpointBulk extraction even by a legitimate accountLow
Server-side authorisation on every objectEverything obscurity would not haveLow
Payload obfuscationCasual inspection only — layer, never relyMedium, ongoing

Recommendation: build every row above the italic one, in that order. If payload obfuscation is still wanted afterwards, add it — it is cheap to layer on top of a sound design, and it does deter casual probing. What must not happen is shipping obfuscation instead of attestation and signing, on the belief that the API is now protected.

App attestation is the control that genuinely answers "only our app may call this API", because it is enforced by Google and Apple using hardware-backed keys the app never holds.

Signed request format

POST /v1/matters
Authorization: Bearer <15-min access token>
X-Clerkify-Timestamp: 1785312000          # unix seconds
X-Clerkify-Nonce: 9f2a...c41d             # 128-bit random, single use
X-Clerkify-Attest: <Play Integrity / App Attest token>
X-Clerkify-Signature: <base64 HMAC-SHA256>

signature = HMAC-SHA256(
    key  = per-session signing key, delivered at login over TLS,
    data = method || path || sha256(body) || timestamp || nonce
)

Server rejects when: the timestamp is outside ±60 seconds, the nonce has been seen before, the signature does not verify, or attestation fails. That is the "expiry" the brief asked for — bound to a specific request rather than to a payload blob, which is what makes it meaningful.

The signing key is per session, issued after authentication, not compiled into the app. A key baked into the binary is shared by every user and cannot be revoked; a per-session key is unique, expiring, and revocable the moment a session is terminated.

Authentication

  • Phone + OTP as primary. Indian advocates are phone-first; email-first login would cost real signups.
  • OTP hardening — per-number and per-IP rate limits, exponential backoff, 5-minute expiry, single use, constant-time comparison, and a hard cap on attempts before lockout.
  • Passkeys as an upgrade path. Offer at second login; strictly better than OTP where the device supports it. Not mandatory — mandating it in this market costs more than it gains.
  • MFA required for owner and admin in agency organisations. Those roles can export the whole case book.
  • Session model — 15-minute access token, 30-day rotating refresh token, refresh reuse detection that revokes the family on replay, device-bound sessions listed and revocable by the user.
  • Biometric unlock on the mobile app for re-entry, backed by the OS keystore. A convenience control, not an authentication factor.

Authorisation

Covered in Tenancy, Roles & Plans. The security-relevant summary: active org_id comes from the token and never from a request parameter; all access goes through org-scoped repositories; Postgres row-level security is the final backstop; and a standing CI suite attempts cross-tenant access against every endpoint, so a new endpoint without a scope check fails the build.

Data protection

In transit

TLS 1.3 everywhere, HSTS preloaded, pinning in the apps. No internal service speaks plaintext, including to the database.

At rest

Volume encryption on database and object storage. Court documents in R2 served only via short-lived signed URLs — never public objects.

Field level

Envelope encryption for phone numbers and any client-identifying notes, so a database dump alone is not a contact list.

Backups

Encrypted, keys held separately from the data. Restore tested weekly.

DPDP Act

Case data is personal data under India's Digital Personal Data Protection Act, and often relates to third parties — the advocate's clients — who are not Clerkify users. Obligations to meet from v1, not retrofit: stated purpose limitation, a defined retention schedule with automatic deletion, export and deletion on request, access logging over personal data, and breach notification readiness. The AUDIT_EVENT mechanism from Tenancy serves the access-logging obligation directly.

Secrets

The old system committed live credentials to 49 repositories. The rules that prevent a recurrence are absolute rather than aspirational:

  1. No secret is ever committed, in any form, to any repository — enforced by gitleaks as a pre-commit hook and a CI gate, because hooks can be skipped.
  2. Secrets live in a manager (SOPS + age, or Infisical) and are injected at runtime.
  3. Every service gets its own credential with the narrowest workable scope. No shared admin user, ever.
  4. Rotation is scheduled and rehearsed, so an emergency rotation is a routine action rather than an outage.
  5. Deploys authenticate via OIDC. No long-lived tokens on servers, and never a token passed as a command-line argument.

Abuse prevention

flowchart TB
    A[Request] --> B[Cloudflare WAF
+ edge rate limit] B --> C{Valid access token?} C -->|no| R1[401] C -->|yes| D{Signature + nonce valid
within time window?} D -->|no| R2[401 + count toward ban] D -->|yes| E{Attestation ok?} E -->|no| R3[403 + flag device] E -->|yes| F{Per-user and per-org
rate limit} F -->|exceeded| R4[429 + backoff] F -->|ok| G{Org scope + role
permits this object?} G -->|no| R5[404 — never 403] G -->|yes| H[Handle request] H --> I[Audit + usage metering]

Fig 10.1 — Request admission path

The 404 on the authorisation branch is deliberate: returning 403 confirms that an object exists, which turns a permission check into an enumeration oracle. Absent objects and forbidden objects should be indistinguishable.

Bulk extraction

The realistic abuse case is not a hacker — it is a legitimate paying account systematically pulling the case corpus. Detection is behavioural: request volume relative to that org's matter count, sequential-ID access patterns, export frequency, and access outside the account's normal hours. Response is graduated — throttle, then alert an operator, then suspend. Hard limits alone cannot distinguish a busy agency from a scraper; behaviour can.

Security automation

Secret scanning

gitleaks pre-commit and in CI. Blocking, not advisory.

Dependency scanning

pip-audit, npm audit, Renovate with grouped weekly updates.

SAST

bandit and semgrep on every PR.

Container scanning

Trivy on every image build; fail on high severity.

Tenant isolation tests

Cross-tenant access attempted against every endpoint, every build.

Auth regression tests

Expired tokens, replayed nonces, tampered signatures — all asserted to fail.

Dashboards

Failed auth rate, 429 rate, attestation failures, unusual export volume.

External review

Independent penetration test before the first agency customer, then annually.

Where to spend the security budget

Ranked by risk reduced per rupee: 1. Secret management and scanning. 2. Tenant isolation with RLS and CI enforcement. 3. Session and token design. 4. App attestation and request signing. 5. Rate limiting and abuse detection. 6. Field-level encryption. 7. Payload obfuscation.

The brief's instinct was to start near the bottom of that list. The instinct behind it — that the API must not be freely usable by anything but the app — is entirely sound, and items 3, 4 and 5 are how it gets delivered.