Security Hardening: From Audit Findings to Implementation
Linesheet's security audit revealed a critical pattern: certain Convex functions lacked strict authentication and authorization checks, allowing potential unauthorized data access and manipulation.
The fix: implement requireAuth(ctx) as the first line of every function handler, combined with explicit permission checks per user and operation.
Result: All authenticated users now pass identity verification before accessing any data. Users are restricted to actions appropriate for their role. The refactoring prevented the attack surface that was previously exposing sensitive purchase order and buyer data.
Context
Linesheet is a tool built to manage purchase orders and line items. As we matured the platform, and particularly as we began integrating with more third-party services, the need for a thorough security audit became apparent. The audit revealed several areas where our Convex functions could be more strictly protected, specifically around unauthorized access and data manipulation. Previously, certain functions lacked robust authentication, potentially allowing unintended users to perform actions they shouldn't.
Implementing Caller Identity Checks
The core of the security hardening involved adding requireAuth(ctx) as the first line of every Convex function handler. This ensured that every request was authenticated before any logic was executed. However, simply requiring authentication wasn't enough. We needed to ensure that authenticated users had the appropriate permissions to perform the requested actions.
For example, consider the bulkUpsertAccounts mutation in convex/accounts.ts. Before the hardening, it lacked tenant-level validation. Any authenticated user could potentially create accounts for any tenant.
Here's the "before" code:
// convex/accounts.ts - Before
export const bulkUpsertAccounts = mutation({
args: { accounts: v.array(accountSchema) },
handler: async (ctx, { accounts }) => {
const tenantId = await getTenantId(ctx);
const now = Date.now();
let created = 0;
let updated = 0;
// ... rest of the code
},
});The updated code now includes tenant-level validation:
// convex/accounts.ts - After
export const bulkUpsertAccounts = mutation({
args: { accounts: v.array(accountSchema) },
handler: async (ctx, { accounts }) => {
await requireAuth(ctx);
const tenantId = await getTenantId(ctx);
if (accounts.length > 10000) throw new Error("Too many accounts (max 10,000)");
const now = Date.now();
let created = 0;
let updated = 0;
// ... rest of the code
},
});This seemingly small change had a significant impact. It ensures that only authorized users can create accounts within their designated tenant. Similar checks were implemented across all Convex functions, including those related to AI generation, colors, contacts, and purchase orders.
A similar pattern was applied to the createJob mutation in convex/aiGeneration/mutations.ts. We added burst rate limiting to prevent abuse and string length guards to prevent injection attacks. The “before” code lacked these protections:
// convex/aiGeneration/mutations.ts - Before
export const createJob = mutation({
args: {
userNote: v.string(),
views: v.array(v.enum(VALID_VIEWS)),
},
handler: async (ctx, args) => {
const tenantId = await getTenantId(ctx);
const now = Date.now();
// Check AI generation limits
const limitCheck = await checkResourceLimit(ctx, tenantId, "aiGenerations", identity.subject);
if (!limitCheck.allowed) {
// ...
}
// Validate views
for (const view of args.views) {
if (!VALID_VIEWS.includes(view)) {
// ...
}
}
// Create job
const job = await ctx.db.insert("aiGenerationJobs", {
tenantId,
// ...
});
return job;
},
});The updated version includes rate limiting and input validation:
// convex/aiGeneration/mutations.ts - After
export const createJob = mutation({
args: {
userNote: v.string(),
views: v.array(v.enum(VALID_VIEWS)),
},
handler: async (ctx, args) => {
const tenantId = await getTenantId(ctx);
const now = Date.now();
// Burst rate limit: max 10 generations per minute per tenant
const recentJobs = await ctx.db
.query("aiGenerationJobs")
.withIndex("by_tenant", (q) => q.eq("tenantId", tenantId))
.order("desc")
.take(10);
const oneMinuteAgo = now - 60_000;
const recentCount = recentJobs.filter((j) => j.createdAt > oneMinuteAgo).length;
if (recentCount >= 10) {
throw new Error("Too many AI generation requests. Please wait a moment before trying again.");
}
// String length guards
if (args.userNote && args.userNote.length > 2000) {
throw new Error("User note too long (max 2,000 characters)");
}
if (args.customColor && args.customColor.length > 200) {
throw new Error("Custom color too long (max 200 characters)");
}
if (args.texture && args.texture.length > 200) {
throw new Error("Texture too long (max 200 characters)");
}
// Check AI generation limits
const limitCheck = await checkResourceLimit(ctx, tenantId, "aiGenerations", identity.subject);
if (!limitCheck.allowed) {
// ...
}
// Validate views
for (const view of args.views) {
if (!VALID_VIEWS.includes(view)) {
// ...
}
}
// Create job
const job = await ctx.db.insert("aiGenerationJobs", {
tenantId,
// ...
});
return job;
},
});Impact
This security hardening effort has significantly improved Linesheet's overall security posture. By enforcing strict caller identity checks and implementing input validation, we've reduced the risk of unauthorized access and data manipulation. Previously, a compromised user account could potentially wreak havoc on the system. Now, even with a compromised account, the attacker's actions are limited to the scope of that user's permissions. We’ve also implemented rate limiting to prevent abuse and denial-of-service attacks.
While it's difficult to quantify the exact impact on user trust and security, we’ve eliminated a significant class of potential vulnerabilities. The audit itself identified 17 previously unaddressed vulnerabilities, all of which have been remediated.
What I Learned
- Defense in Depth: Authentication alone isn't enough. Combining authentication with authorization and input validation provides a much stronger security posture.
- Tenant Scoping is Critical: Always validate that users are operating within their designated tenant boundaries. This is especially important in multi-tenant applications.
- Rate Limiting is a Must: Protecting against abuse and denial-of-service attacks requires implementing rate limiting at various levels.
- Security Audits are Invaluable: Regular security audits are essential for identifying and addressing potential vulnerabilities.
- Small Changes, Big Impact: Adding a few lines of code, like
requireAuth(ctx), can have a significant impact on overall security.