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:
| Threat | Likelihood | Impact | Primary control |
|---|---|---|---|
| Credential leak via repo or laptop | High | Critical | Secret manager, scanning in CI, short-lived tokens |
| Cross-tenant data access | Medium | Critical | Postgres RLS + repository scoping (Tenancy) |
| API abuse by a scraped client | High | Medium | Attestation, signing, rate limits, quotas |
| Account takeover | Medium | High | OTP rate limiting, device binding, optional passkeys |
| Stolen or lost device | Medium | Medium | Short sessions, remote revocation, biometric unlock |
| Insider access to production data | Low | Critical | Scoped credentials, access logging, no shared admin user |
| Network interception | Low | High | TLS 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.
| Control | Stops | Cost |
|---|---|---|
| Short-lived access tokens — 15 min, rotating refresh, revocable | Long-term use of a stolen token | Low |
| HMAC request signing — over method, path, body hash, timestamp, nonce | Tampering, and this is where expiry lives | Low |
| Nonce cache + timestamp window — 60s window, nonce single-use in Redis | Replay of a captured request | Low |
| App attestation — Play Integrity, App Attest | Calls from anything that is not the genuine app. The actual answer to the brief's concern. | Medium |
| Certificate pinning | Casual traffic inspection | Low, but plan for rotation |
| Rate limits + quotas — per user, org, IP, endpoint | Bulk extraction even by a legitimate account | Low |
| Server-side authorisation on every object | Everything obscurity would not have | Low |
| Payload obfuscation | Casual inspection only — layer, never rely | Medium, 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
ownerandadminin 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:
- No secret is ever committed, in any form, to any repository — enforced by
gitleaksas a pre-commit hook and a CI gate, because hooks can be skipped. - Secrets live in a manager (SOPS + age, or Infisical) and are injected at runtime.
- Every service gets its own credential with the narrowest workable scope. No shared admin user, ever.
- Rotation is scheduled and rehearsed, so an emergency rotation is a routine action rather than an outage.
- 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.