Security: Hardening the x-org-id Cookie (SEC-16)
Security Deep-Dive: Hardening the x-org-id Cookie (SEC-16)
Release: v1.0.49 · Control: SEC-16 · Severity: High
Background
Calmony Pay is a multi-tenant API. Every request must be resolved to a specific organisation so that payment records, invoices, and settlement accounts are scoped correctly. To carry this context across requests, the platform uses an x-org-id cookie that is read inside createTRPCContext on every tRPC call.
Prior to v1.0.49, this cookie was written from client-side JavaScript in src/components/org-provider.tsx, and no explicit cookie security attributes were set.
What Was the Risk?
1. JavaScript Tampering (Missing HttpOnly)
Without the HttpOnly flag, any JavaScript running in the browser — including third-party scripts, browser extensions, or injected code via an XSS vulnerability — could read or overwrite the x-org-id cookie:
// Previously possible by any script on the page
document.cookie = 'x-org-id=another-org-uuid; path=/';
If an attacker could set this value to a different organisation's ID, subsequent tRPC calls might resolve and return that organisation's data, depending on how session validation was layered.
2. CSRF Exposure (Missing SameSite)
Without a SameSite attribute, browsers default to SameSite=Lax (or None in older browsers). Under Lax, cross-site navigations that trigger top-level GET requests will still send the cookie. A malicious page could construct a request that carries a forged x-org-id value alongside the user's real session cookie, potentially causing org-context confusion on state-changing operations.
3. Plaintext Transmission Risk (Missing Secure)
Without the Secure flag there is no browser-enforced guarantee that the cookie is only transmitted over HTTPS, leaving it visible on any unencrypted connection.
What Changed in v1.0.49
The x-org-id cookie is no longer written from org-provider.tsx using client-side JavaScript. It is now issued from a server-side context (a Next.js server action or API route) with all three security attributes explicitly set:
// Server action / API route — server-side only
cookies().set('x-org-id', resolvedOrgId, {
httpOnly: true, // not readable by JavaScript
secure: true, // HTTPS only
sameSite: 'strict', // no cross-site sending
path: '/',
});
Why SameSite=Strict?
Strict is the most conservative option. The cookie is never sent on a cross-site request, including top-level navigations originating from another domain. This is appropriate here because:
- Org-context resolution is not needed for cross-site link clicks.
- The cookie carries a security-sensitive tenant identifier, not a display preference.
- Strict mode eliminates the entire cross-site cookie-leakage surface.
Defence in Depth
In addition to the cookie attribute changes, the recommended posture for createTRPCContext is to validate the x-org-id value against the authenticated session rather than trusting it unconditionally. For example:
// createTRPCContext — illustrative validation pattern
const session = await getServerSession();
const cookieOrgId = cookies().get('x-org-id')?.value;
// Confirm the user is actually a member of the org in the cookie
const org = await db.orgMembership.findFirst({
where: { orgId: cookieOrgId, userId: session.user.id },
});
if (!org) {
throw new TRPCError({ code: 'FORBIDDEN' });
}
This ensures that even if a cookie value were somehow forged, the session-level membership check would block access to the wrong organisation's data.
Summary of Changes
| What | Before v1.0.49 | From v1.0.49 |
|---|---|---|
| Cookie written from | Client-side JS (org-provider.tsx) | Server action / API route |
HttpOnly | ❌ Not set | ✅ true |
Secure | ❌ Not set | ✅ true |
SameSite | ❌ Not set (browser default) | ✅ Strict |
| JS-accessible | ✅ Yes | ❌ No |
| Cross-site leakage | ⚠️ Possible | ❌ Blocked |
Affected File
src/components/org-provider.tsx— client-side cookie write removed; org context now flows from server-issued cookie.