Contents
- Authentication vs. Authorization (The Core Distinction)
- Separate Data Stores (Client vs. Employee)
- Client-Portal Isolation (Per-Client D1 vs. Shared with Tenant Filtering)
- Authentication Provider (Managed vs. Hand-Built)
- Cross-Domain VA Reference (Employees on Client Dashboards)
- Concrete Test Scenarios
- Reusable Security Protocol Checklist
- Full Architecture Diagram
- Endpoint-by-Endpoint Authorization Map
Authentication vs. Authorization
Separate login pages do NOT mean data is isolated. Authentication answers "who is this person?" Authorization answers "what can this person access?" A system can authenticate someone perfectly and still hand them data they should never see if authorization is not enforced on every single request, server-side, without trusting anything the client sends.
Authentication = verifying identity. "This session belongs to user #472, who is a client_owner at ABC Landscaping." This happens once at login. Clerk (or whatever auth provider) handles this and issues a signed JWT.
Authorization = enforcing access rules on every request. "User #472 is requesting the performance dashboard for client #38. Is user #472 authorized to see client #38's data?" This happens on every single API call, server-side, by deriving the user's identity from the signed JWT and checking it against the database.
How Authorization Is Enforced: Endpoint by Endpoint
The server NEVER trusts a client_id, tenant_id, or user_id sent from the browser. Identity is always derived from the JWT, and permissions are checked against the database before any data is returned.
Key Principle: Server-Side Identity Derivation
- The browser sends a JWT (set by Clerk at login). That is the only piece of client-sent data we trust, because it is cryptographically signed and verified against Clerk's public key.
- From the verified JWT, we extract the user ID. From the user ID, we look up the client_id and role in our database.
- The client_id and role are NEVER sent by the browser. They are derived server-side, every time, on every request.
- If an attacker steals a session token, they can only access data for the client that token is associated with. They cannot pivot to another client's data because the client_id is bound to the session server-side.
- "We tag content by client" is NOT the answer. The answer is: we derive the client identity from a cryptographically verified JWT and use that derived identity as the sole filter on every database query.
Separate Data Stores: Client vs. Employee
Two completely separate D1 databases. Client data and employee data never share a store.
Employee HR data (payroll, health insurance, pay stubs) is a higher sensitivity class than client business performance data. A breach of the client portal must be structurally unable to expose employee payroll. Separate databases enforce this at the infrastructure level.
D1 Database: oa-employee-db
- ~400 OA employees
- Payroll records + pay stubs
- Health insurance enrollment
- Department/division KPIs for leaders
- HR announcements, holiday calendars
- Strategic initiatives (admin-pushed)
- Sensitivity: HIGH (PII, compensation, health)
D1 Database: oa-client-db
- ~150 client companies, each with 1+ users
- VA performance dashboards (HubSpot)
- Time tracking (Time Doctor)
- Industry research, resources
- Satisfaction surveys, staff feedback
- Owner vs. employee permission tiers
- Sensitivity: MEDIUM (business data)
Why Separate Databases (Not Just Separate Tables)
Blast-Radius Containment
- Infrastructure-level isolation: The client portal Worker binds to
oa-client-db. It physically cannot queryoa-employee-dbbecause it has no binding to it. A full remote code execution exploit in the client portal Worker still cannot reach employee payroll because the connection does not exist at the Cloudflare runtime level. - Different sensitivity classes: Employee compensation and health data may trigger compliance requirements (SOC 2, data residency, retention policies) that client business data does not. Separate stores let you apply different policies cleanly.
- Independent failure domains: A bad migration on the employee schema cannot lock or corrupt the client database. A D1 storage quota event on one cannot affect the other.
- Zero data overlap: No table in the employee database has a foreign key relationship to any table in the client database. They share no schema. Combining them into one database would be two databases sharing a container for no reason.
A single Cloudflare Worker can bind to multiple D1 databases simultaneously. The admin panel Worker at admin.outsourceaccess.com binds to BOTH oa-employee-db and oa-client-db. OA staff log in once and can push content to either portal, manage users on either side, and view analytics from both. The admin panel is the bridge, but the underlying data stays in separate stores.
Client-Portal Isolation: Per-Client D1 vs. Shared with Tenant Filtering
Within the client database, there are ~150 client companies. Each must see only their own data. Two architectural options exist. Here is an honest comparison on security grounds, with cost explicitly off the table.
Option A: Separate D1 Database Per Client (~150 databases)
- Isolation mechanism: Architectural. Each client's Worker route binds to only that client's D1 database. A bug in one client's query path physically cannot leak another client's data because the binding does not exist.
- Breach blast radius: One client only. If client #38's database is compromised, clients #1-37 and #39-150 are untouched.
- Query safety: No WHERE clause needed. Every query in the Worker returns data for only that client, by definition. A forgotten filter cannot cause a cross-tenant leak because there is no cross-tenant data to leak.
- Cost: $0. D1 free tier is per-database. 150 databases each get 5GB and 5M reads/day independently.
- Operational cost: Slightly more complex deployment. Schema migrations run 150 times. Client onboarding creates a new database + Worker binding. INFERRED This can be fully automated with a provisioning script.
- Cross-client analytics: Harder. Admin panel would need to query 150 databases for aggregate reporting (e.g., "average satisfaction score across all clients"). INFERRED Solvable with a periodic aggregation job that writes summary stats to an analytics D1.
Option B: One Shared Client Database with Tenant Filtering
- Isolation mechanism: Discipline. Every query must include
WHERE client_id = ?with a server-derived client_id. The filtering middleware shown in Section 1 enforces this centrally. - Breach blast radius: All clients. If the database is compromised (credential leak, SQL injection that bypasses parameterization), all 150 clients' data is exposed.
- Query safety: Depends on every query being written correctly. The middleware pattern below makes this enforceable, but it is still software discipline, not structural impossibility.
- Cost: $0. One database, same free tier.
- Operational cost: Simpler deployment. One schema, one migration, one database. Standard multi-tenant pattern used by most SaaS companies.
- Cross-client analytics: Trivial. One query across all data with GROUP BY client_id.
Recommendation: Option A (Per-Client D1 Databases)
Since cost is not a factor, per-client databases provide stronger security by making cross-client data leaks structurally impossible. The operational complexity (automated provisioning, multi-database migrations) is a one-time engineering cost that Sterling can build. The security benefit is permanent: no future developer can accidentally write a query that leaks Client A's data to Client B, because Client A's Worker binding physically does not include Client B's database.
If You Choose Option B: How to Make Tenant Filtering Airtight
Option B is viable IF the tenant filter is enforced centrally so no individual query can bypass it. Here is the pattern. INFERRED This is the standard approach used by enterprise SaaS. It is well-proven but depends on discipline.
Option A (per-client databases) makes cross-client leaks structurally impossible. Option B (shared database + TenantScopedDB) makes cross-client leaks practically very difficult but relies on the middleware always being in the request chain and the raw DB binding never leaking to handlers. With ~150 clients and cost off the table, Option A is the stronger security posture.
Authentication: Managed Provider vs. Hand-Built
Brad's directive: cost is off the table. Evaluate on security alone.
Recommendation: Clerk (managed auth provider)
Authentication is the one component where getting it 95% right is worse than getting it 100% right. The 5% you miss (token rotation edge cases, session fixation, cookie SameSite behavior across browsers, brute-force protection, credential stuffing mitigation) are exactly the attack vectors that sophisticated attackers exploit. Clerk's team works on nothing but these problems. Sterling can build auth in 8 minutes, but Clerk's team has prevented auth bugs Sterling would never anticipate because they see millions of auth requests across thousands of apps.
Managed Auth (Clerk)
- Security surface area Sterling does NOT manage: Password hashing, salt generation, timing-safe comparison, bcrypt cost factor tuning, credential stuffing detection, brute-force rate limiting, CAPTCHA integration, session token rotation, refresh token revocation, cookie security flags (HttpOnly, Secure, SameSite), CSRF protection, OAuth flow security, JWT signing key rotation
- Compliance: SOC 2 Type II certified. They maintain the audit trail, not us.
- Multi-factor auth: Built in. TOTP, SMS, email magic links. We turn it on with a flag.
- Session management: Automatic token rotation, device tracking, session revocation from dashboard.
- SSO/SAML: Available if enterprise clients want to use their own identity provider. INFERRED Not needed today but eliminates a future rebuild.
- Free for the first 10,000 monthly active users. OA has ~900 total users. $0 for the foreseeable future.
Hand-Built Auth
- Security surface area Sterling DOES manage: All of the items listed to the left. Every one is a potential vulnerability if implemented imperfectly.
- Sterling builds fast, but auth bugs are subtle: A timing side-channel in password comparison leaks information. A missing SameSite=Lax flag enables CSRF. A refresh token that does not get rotated enables persistent session hijacking. These are not bugs you find in testing. They are bugs attackers find in production.
- Ongoing maintenance: When Chrome ships a new cookie policy (they do this regularly), when Apple changes WebKit session storage behavior, when a new class of session fixation attack is published, Sterling has to update. Clerk does this automatically.
- The "warranty" argument: Clerk is a specialist whose entire business depends on auth being correct. Hand-built auth has no warranty. Sterling IS the warranty, and every hour spent debugging an auth edge case is an hour not spent on revenue-moving work.
Clerk handles identity (login, session tokens, MFA). OA's D1 databases handle authorization (which client does this user belong to, what role do they have, what data can they see). Clerk's JWT tells us WHO. Our database tells us WHAT THEY CAN DO. The two are separate concerns, as they should be.
Cross-Domain Link: VAs on Client Dashboards
VAs are OA employees. Their names and performance metrics appear on client dashboards. This creates a cross-domain reference between the employee database and the client database. The solution: narrow reference, not database merging.
Approach: VA Summary Table in the Client Database
The client database contains a va_assignments table with the minimal information a client needs to see: VA display name, profile photo URL, start date, and role. This is NOT a copy of the employee record. It is a purpose-built, minimal reference that contains only what the client portal needs to render the dashboard.
- What the client DB stores:
va_display_name,va_photo_url,va_start_date,va_role_title,employee_ref_id(opaque reference, not the employee's internal DB primary key). - What the client DB does NOT store: Payroll, health insurance, personal email, phone number, department assignments, KPI scores, HR records. None of that exists in the client database.
- Sync mechanism: A scheduled Worker job (Cron Trigger) reads minimal VA assignment info from the employee DB and writes the summary fields to the client DB. This runs on admin infrastructure (admin Worker, which has both bindings). INFERRED
- The employee_ref_id is opaque: It is a UUID or hash, not the employee's D1 primary key. Even if a client somehow obtained this ID, they could not use it to query the employee database because the client portal Worker has no binding to that database.
graph LR
subgraph EmpDB["oa-employee-db"]
E["employees table
Full record: name, email,
payroll, health, department"]
end
subgraph Sync["Admin Worker (Cron)"]
S["Copies ONLY:
display_name, photo_url,
start_date, role_title"]
end
subgraph ClientDB["oa-client-db"]
V["va_assignments table
Minimal: display_name,
photo_url, start_date,
role_title, employee_ref_id"]
end
E -->|"Minimal fields only"| S
S -->|"Write summary"| V
style EmpDB fill:#1a1a2e,stroke:#dc2626,color:#fff
style ClientDB fill:#1a1a2e,stroke:#059669,color:#fff
style Sync fill:#48A7DB,stroke:#48A7DB,color:#fff
The employee database keeps the full record. The client database gets a purpose-built summary with only what the client dashboard needs. No database merging. No shared tables. No foreign key relationships across databases.
Concrete Test Scenarios
Every isolation claim ships with a test. These are specific, runnable scenarios that prove the authorization model works.
Cross-Client Isolation Tests
Test 1: Client A attempts to fetch Client B's performance data by manipulating the URL
Test 2: Client A attempts to fetch Client B's record by guessing the record ID
SELECT * FROM satisfaction_surveys WHERE id = 999 AND client_id = 38. Returns 0 rows because survey 999 belongs to client 42, not 38. Response: 404 Not Found. DENIED - 404Test 3: Client session attempts to reach employee data
Test 4: Client employee (non-owner) attempts to access owner-only feedback
Test 5: Unauthenticated request to any API endpoint
Test 6: Forged JWT with a different client_id claim
Admin Panel Tests
Test 7: Non-admin user attempts to access admin panel
Reusable Security Protocol Checklist
This checklist applies to every portal, every endpoint, and every new feature added to the OA platform. It is the standing security contract.
Security Checklist (Every Endpoint, Every Feature)
Before any new endpoint or feature goes live, walk through all 6 items. For items 1-2, confirm the endpoint uses the authorization middleware and never reads IDs from the request. For item 3, confirm which isolation mechanism applies. For item 4, confirm the new endpoint is deny-by-default. For item 5, count how many layers protect this data. For item 6, write the test scenario.
Full Architecture Diagram
graph TB
subgraph DNS["DNS Layer"]
A["server.outsourceaccess.com"]
B["clients.outsourceaccess.com"]
C["admin.outsourceaccess.com"]
end
subgraph Auth["Clerk (Managed Auth)"]
CK1["Client Clerk App
JWT signing + MFA"]
CK2["Employee Clerk App
JWT signing + MFA"]
CK3["Admin Clerk App
JWT + MFA + IP whitelist"]
end
subgraph Workers["Cloudflare Workers - API + Authorization"]
W1["Employee Worker
1. Verify Employee JWT
2. Derive employee_id from DB
3. Check role permissions
4. Return scoped data"]
W2["Client Worker
1. Verify Client JWT
2. Derive client_id from DB
3. Check role (owner/mgr/viewer)
4. Return scoped data"]
W3["Admin Worker
1. Verify Admin JWT
2. Check admin role
3. Route to correct DB
4. Audit log all writes"]
end
subgraph EmpDB["D1: oa-employee-db (HIGH sensitivity)"]
E1[("employees
payroll_records
pay_stubs")]
E2[("health_insurance
kpi_metrics
announcements")]
end
subgraph ClientDB["D1: oa-client-db (or per-client DBs)"]
C1[("clients
client_users
va_assignments")]
C2[("va_performance
time_tracking
satisfaction_surveys")]
C3[("staff_feedback
industry_content
resources")]
end
subgraph Sources["External Data Sources"]
HS["HubSpot API"]
TD["Time Doctor API"]
GD["Google Drive"]
end
A --> CK2
B --> CK1
C --> CK3
CK2 -->|"Verified JWT"| W1
CK1 -->|"Verified JWT"| W2
CK3 -->|"Verified JWT"| W3
W1 -->|"ONLY binding"| E1
W1 --> E2
W2 -->|"ONLY binding"| C1
W2 --> C2
W2 --> C3
W3 -->|"Binding 1"| E1
W3 -->|"Binding 2"| C1
HS --> C2
TD --> C2
GD --> C3
style DNS fill:#362456,stroke:#362456,color:#fff
style Auth fill:#7c3aed,stroke:#7c3aed,color:#fff
style Workers fill:#48A7DB,stroke:#48A7DB,color:#fff
style EmpDB fill:#1a1a2e,stroke:#dc2626,color:#fff
style ClientDB fill:#1a1a2e,stroke:#059669,color:#fff
style Sources fill:#f0f0f0,stroke:#ccc,color:#333
Red border = Employee DB (payroll, health, HR). Green border = Client DB (performance, content, feedback). Each Worker binds ONLY to its own database. The Admin Worker is the sole bridge.
Endpoint-by-Endpoint Authorization Map
Every API endpoint, what database it touches, how identity is derived, and what authorization check runs.
Client Portal Endpoints (clients.outsourceaccess.com)
| Endpoint | DB | Identity Source | Authorization Check | Roles Allowed |
|---|---|---|---|---|
GET /api/client/performance |
oa-client-db | JWT → clerk_id → client_users.client_id | Filter by server-derived client_id | owner, manager, viewer |
GET /api/client/time-tracking |
oa-client-db | JWT → clerk_id → client_users.client_id | Filter by server-derived client_id | owner, manager, viewer |
GET /api/client/surveys |
oa-client-db | JWT → clerk_id → client_users.client_id | Filter by server-derived client_id | owner, manager |
GET /api/client/feedback |
oa-client-db | JWT → clerk_id → client_users.client_id | Filter by client_id + role = owner | owner ONLY |
GET /api/client/resources |
oa-client-db | JWT → clerk_id → client_users.client_id | Filter by client industry tag | owner, manager, viewer |
POST /api/client/users/invite |
oa-client-db | JWT → clerk_id → client_users.client_id | role = owner + same client_id | owner ONLY |
Employee Portal Endpoints (server.outsourceaccess.com)
| Endpoint | DB | Identity Source | Authorization Check | Roles Allowed |
|---|---|---|---|---|
GET /api/employee/payroll |
oa-employee-db | JWT → clerk_id → employees.id | Filter by server-derived employee_id (own records only) | All employees (own data) |
GET /api/employee/health-insurance |
oa-employee-db | JWT → clerk_id → employees.id | Filter by server-derived employee_id | All employees (own data) |
GET /api/employee/kpis |
oa-employee-db | JWT → clerk_id → employees.department_id | Filter by department_id + role >= team_leader | team_leader, ops_manager, admin, owner |
GET /api/employee/announcements |
oa-employee-db | JWT → clerk_id → employees.id | Public to all authenticated employees | All employees |
Admin Panel Endpoints (admin.outsourceaccess.com)
| Endpoint | DB | Identity Source | Authorization Check | Roles Allowed |
|---|---|---|---|---|
POST /api/admin/content/push |
Both (routed) | JWT → admin_users table | Admin role + target portal validation | OA admin staff only |
GET /api/admin/clients/list |
oa-client-db | JWT → admin_users table | Admin role required | OA admin staff only |
POST /api/admin/employees/create |
oa-employee-db | JWT → admin_users table | Admin role + HR permission flag | OA HR admin only |
GET /api/admin/audit-log |
Both | JWT → admin_users table | Admin owner role | Brad + senior admin only |
Every endpoint follows the same pattern: (1) JWT verified by Clerk, (2) user ID extracted from verified JWT, (3) user looked up in the appropriate database to get role + scope, (4) authorization check runs against the server-derived role + scope, (5) database query filtered by server-derived identity. No endpoint trusts client-sent IDs. No endpoint skips the authorization middleware.
Decision Summary
Architecture Decisions
1. Auth vs. Authz: Clerk handles authentication (identity). OA's Workers enforce authorization (access control) on every request, server-side, deriving identity from signed JWTs and never trusting client-sent IDs.
2. Data stores: Two separate D1 databases (oa-employee-db, oa-client-db). Different sensitivity classes, different blast radii. Admin Worker bridges both.
3. Per-client isolation: Recommended: per-client D1 databases (~150 DBs, $0 cost). Isolation enforced by architecture, not discipline. If shared model chosen, enforce via TenantScopedDB middleware pattern.
4. Auth provider: Clerk (managed). Auth is the one domain where specialist > generalist. Free for the first 10K MAU. SOC 2 compliant. Handles the subtle edge cases (token rotation, brute-force, session fixation) that hand-built auth misses.
5. VA cross-reference: Minimal va_assignments table in client DB with display-only fields. No database merging. Opaque employee reference ID.
6. Every isolation claim has a test: 7 concrete test scenarios with GIVEN/WHEN/THEN + mechanistic code explanations.
7. Security checklist: 6-point reusable protocol. Apply to every new endpoint and feature.