How Cal.com Rebuilt AppSec After Going Closed Source
How Cal.com consolidated noisy security tooling into one continuous, context-aware pull request security program with Gecko.
Gecko Security

Cal.com is an open-source scheduling infrastructure that provides a developer-friendly alternative to Calendly. It offers a self-hostable or cloud-hosted platform with calendar syncing, availability management, team scheduling, built-in video conferencing, and an API for embedding booking experiences into any application.
Gecko’s AI security engineer discovered several critical and high-impact chained vulnerabilities in Cal.com Cloud that allowed an attacker to perform complete account takeover on any Cal.com user and read or modify any booking, including private meetings with attendee metadata.
Throughout the vulnerability discovery process, Gecko autonomously identified all findings and enabled us to uncover complex multi-step vulnerability chains in a few hours that otherwise could have taken weeks and had slipped past existing tooling and manual penetration testing Cal.com undergoes.
This is what we’re building toward at Gecko: democratizing AI-augmented security expertise and turning it into something every developer and security team can use. We’re building the infrastructure, tools, agents, and workflows that capture security knowledge and amplify it with LLMs, putting that power directly in people’s hands to secure software at scale.
The rest of this post walks through the vulnerability chain and how AI took an unfamiliar codebase and found and validated critical findings.
We would like to thank the security team at Cal.com for their swift response and collaboration in resolving these issues. After these vulnerabilities were reported, they were patched and pushed within a few days.
Before diving into the vulnerabilities, let’s talk about the tool that made this research possible.
Gecko is an AI-powered static analysis platform that approaches code security differently than traditional SAST tools. Instead of pattern matching against predefined rules, Gecko builds a semantic index of your codebase using language servers, the same way your IDE understands your code. This gives us compiler-accurate symbol resolution across files and repositories, which means we can trace complex call chains and reason about business logic in ways that AST-based scanners cannot.
During the indexing process, Gecko identifies endpoints, maps authentication and authorization mechanisms, and builds a graph of how data flows through the application. This allows us to generate accurate proof-of-concepts, scan across microservices, and identify missing authorization on critical code paths.
We’re making Gecko free for a limited time. We invite developers, vulnerability researchers, and security engineers to try our preview. Finding vulnerabilities should be as accessible as writing code with AI.
Broken access control vulnerabilities exist in virtually every application. As OWASP noted in their 2025 Top 10 List:
“Maintaining its position at #1 in the Top Ten, 100% of the applications tested were found to have some form of broken access control.”
Cal.com’s mission is to connect 1 billion people by 2031, and being open source is a big part of their philosophy. They have nearly 1,000 contributors and a strong focus on security, which made them a good target to test OWASP’s claims against a security-conscious codebase.
The most critical finding was an authentication bypass in the signup flow that allowed attackers to take over existing user accounts by exploiting organization team invite tokens. When a user is already a member of an organization, the username validation logic incorrectly returns available: true, allowing the signup process to proceed and overwrite the victim’s password. This grants the attacker complete access to their account.
The vulnerability stems from three chained bugs:
This chain enables any attacker with an organization to take over accounts of users in different organizations by simply knowing their email address.
The usernameCheckForSignup function finds existing users by email but only validates availability if they are not organization members.
<code class="hljs language-typescript"><span class="hljs-keyword">const</span> <span class="hljs-title function_">usernameCheckForSignup</span> = <span class="hljs-keyword">async</span> (<span class="hljs-params">{ username, email }</span>) => {
<span class="hljs-keyword">const</span> response = {
<span class="hljs-attr">available</span>: <span class="hljs-literal">true</span>, <span class="hljs-comment">// Default assumes email is available</span>
<span class="hljs-attr">premium</span>: <span class="hljs-literal">false</span>,
<span class="hljs-attr">suggestedUsername</span>: <span class="hljs-string">""</span>,
};
<span class="hljs-keyword">const</span> username = <span class="hljs-title function_">slugify</span>(usernameRaw);
<span class="hljs-comment">// Find existing user by email (global search)</span>
<span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> prisma.<span class="hljs-property">user</span>.<span class="hljs-title function_">findUnique</span>({
<span class="hljs-attr">where</span>: { email },
<span class="hljs-attr">select</span>: { <span class="hljs-attr">id</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">username</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">organizationId</span>: <span class="hljs-literal">true</span> },
});
<span class="hljs-keyword">if</span> (user) {
<span class="hljs-comment">// Check if user belongs to any organization</span>
<span class="hljs-keyword">const</span> userIsAMemberOfAnOrg = <span class="hljs-keyword">await</span> prisma.<span class="hljs-property">membership</span>.<span class="hljs-title function_">findFirst</span>({
<span class="hljs-attr">where</span>: {
<span class="hljs-attr">userId</span>: user.<span class="hljs-property">id</span>,
<span class="hljs-attr">team</span>: { <span class="hljs-attr">isOrganization</span>: <span class="hljs-literal">true</span> },
},
});
<span class="hljs-comment">// Vulnerability 1: Only validates if user is NOT in an org</span>
<span class="hljs-keyword">if</span> (!userIsAMemberOfAnOrg) {
<span class="hljs-comment">// This validation only runs for non-org users</span>
<span class="hljs-keyword">const</span> isClaimingAlreadySetUsername = user.<span class="hljs-property">username</span> === username;
<span class="hljs-keyword">const</span> isClaimingUnsetUsername = !user.<span class="hljs-property">username</span>;
response.<span class="hljs-property">available</span> = isClaimingUnsetUsername || isClaimingAlreadySetUsername;
response.<span class="hljs-property">premium</span> = <span class="hljs-keyword">await</span> <span class="hljs-title function_">isPremiumUserName</span>(username);
}
<span class="hljs-comment">// If userIsAMemberOfAnOrg is true, response.available stays TRUE</span>
<span class="hljs-comment">// This allows org members to be "re-signed up" by attackers</span>
}
<span class="hljs-keyword">return</span> response; <span class="hljs-comment">// Returns { available: true } for org members</span>
};
</code>When a user belongs to any organization, the validation logic is skipped entirely, leaving the available flag at its default value of true. This incorrectly signals that the email is available for signup, allowing the process to continue even though an active account exists. The function should reject all existing verified users regardless of organization membership, but instead it creates a dangerous exception for the exact users most vulnerable to cross-organization attacks.
The second validation only searches for existing users within the target organization’s scope.
<code class="hljs language-typescript"><span class="hljs-keyword">const</span> existingUser = <span class="hljs-keyword">await</span> prisma.<span class="hljs-property">user</span>.<span class="hljs-title function_">findFirst</span>({
<span class="hljs-attr">where</span>: {
<span class="hljs-comment">// Vulnerability 2: Only searches within the target organization</span>
...(organizationId ? { organizationId } : {}), <span class="hljs-comment">// WHERE organizationId = attacker's org</span>
<span class="hljs-attr">OR</span>: [
<span class="hljs-comment">// Skip username check in org context</span>
...(!organizationId ? [{ username }] : [{}]),
{
<span class="hljs-attr">AND</span>: [
{ email }, <span class="hljs-comment">// Check for this email</span>
{
<span class="hljs-attr">OR</span>: [
{ <span class="hljs-attr">emailVerified</span>: { <span class="hljs-attr">not</span>: <span class="hljs-literal">null</span> } }, <span class="hljs-comment">// Email is verified</span>
{ <span class="hljs-attr">AND</span>: [{ <span class="hljs-attr">password</span>: { <span class="hljs-attr">isNot</span>: <span class="hljs-literal">null</span> } }, { <span class="hljs-attr">username</span>: { <span class="hljs-attr">not</span>: <span class="hljs-literal">null</span> } }] },
],
},
],
},
],
},
<span class="hljs-attr">select</span>: { <span class="hljs-attr">email</span>: <span class="hljs-literal">true</span> },
});
<span class="hljs-comment">// This translates to SQL:</span>
<span class="hljs-comment">// SELECT email FROM User </span>
<span class="hljs-comment">// WHERE organizationId = <attacker_org_id> ← Only checks attacker's org</span>
<span class="hljs-comment">// AND email = 'victim@email.com'</span>
<span class="hljs-comment">// AND emailVerified IS NOT NULL</span>
<span class="hljs-comment">// If victim is in a different org, query returns NULL</span>
<span class="hljs-keyword">return</span> { <span class="hljs-attr">isValid</span>: !existingUser }; <span class="hljs-comment">// Returns true = email "available"</span>
</code>This translates to a SQL query with WHERE organizationId = <attacker_org_id>. When the victim belongs to a different organization, this scoped query returns no results, causing the validator to incorrectly conclude the email is available. The function asks “Does this email exist in MY organization?” when it should ask “Does this email exist anywhere as a verified user?”
After both validations incorrectly pass, the handler executes a prisma.user.upsert() operation with where: { email }.
<code class="hljs language-typescript"><span class="hljs-keyword">if</span> (foundToken && foundToken?.<span class="hljs-property">teamId</span>) {
<span class="hljs-keyword">const</span> team = <span class="hljs-keyword">await</span> prisma.<span class="hljs-property">team</span>.<span class="hljs-title function_">findUnique</span>({
<span class="hljs-attr">where</span>: { <span class="hljs-attr">id</span>: foundToken.<span class="hljs-property">teamId</span> },
<span class="hljs-attr">include</span>: {
<span class="hljs-attr">parent</span>: { <span class="hljs-attr">select</span>: { <span class="hljs-attr">id</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">slug</span>: <span class="hljs-literal">true</span>, <span class="hljs-attr">organizationSettings</span>: <span class="hljs-literal">true</span> } },
<span class="hljs-attr">organizationSettings</span>: <span class="hljs-literal">true</span>,
},
});
<span class="hljs-keyword">if</span> (team) {
<span class="hljs-keyword">const</span> organizationId = team.<span class="hljs-property">isOrganization</span> ? team.<span class="hljs-property">id</span> : team.<span class="hljs-property">parent</span>?.<span class="hljs-property">id</span> ?? <span class="hljs-literal">null</span>;
<span class="hljs-comment">// Vulnerability 3: Email is globally unique, so this finds any user with this email</span>
<span class="hljs-keyword">const</span> user = <span class="hljs-keyword">await</span> prisma.<span class="hljs-property">user</span>.<span class="hljs-title function_">upsert</span>({
<span class="hljs-attr">where</span>: { email }, <span class="hljs-comment">// Matches victim's email across all orgs</span>
<span class="hljs-attr">update</span>: {
username, <span class="hljs-comment">// Changes username</span>
<span class="hljs-attr">emailVerified</span>: <span class="hljs-keyword">new</span> <span class="hljs-title class_">Date</span>(<span class="hljs-title class_">Date</span>.<span class="hljs-title function_">now</span>()),
<span class="hljs-attr">identityProvider</span>: <span class="hljs-title class_">IdentityProvider</span>.<span class="hljs-property">CAL</span>,
<span class="hljs-attr">password</span>: {
<span class="hljs-attr">upsert</span>: {
<span class="hljs-attr">create</span>: { <span class="hljs-attr">hash</span>: hashedPassword },
<span class="hljs-attr">update</span>: { <span class="hljs-attr">hash</span>: hashedPassword }, <span class="hljs-comment">// Overwrites victim's password</span>
},
},
organizationId, <span class="hljs-comment">// Moves victim to attacker's org</span>
},
<span class="hljs-attr">create</span>: {
<span class="hljs-comment">// This block won't execute, victim already exists</span>
username,
email,
<span class="hljs-attr">identityProvider</span>: <span class="hljs-title class_">IdentityProvider</span>.<span class="hljs-property">CAL</span>,
<span class="hljs-attr">password</span>: { <span class="hljs-attr">create</span>: { <span class="hljs-attr">hash</span>: hashedPassword } },
organizationId,
},
});
<span class="hljs-comment">// Victim is now locked out, attacker has full access</span>
}
}
</code>Since email addresses are globally unique in the database schema, this clause matches the victim’s existing user record regardless of organization. The update block then executes, overwriting the victim’s password hash with the attacker’s chosen password and changing their organizationId to the attacker’s organization. The victim is immediately locked out of their account, and the attacker gains full access. All of the victim’s data, including calendar integrations, OAuth tokens, bookings, and API keys, becomes accessible to the attacker.
The exploit is straightforward. An attacker generates a shareable invite link for an organization they own, producing a URL like https://app.cal.com/signup?token=<64-char-hex-token>. They navigate to the URL and fill in the signup form with any victim’s email and a new password. Signup succeeds, and the attacker now has complete account takeover. The victim’s original password no longer works. No notification is sent to the victim.
Cal.com fixed this in v6.0.8 by adding user existence validation before signup with invite tokens.

The second vulnerability exposed all booking data and user records through two basic flaws: missing access controls on endpoints and Insecure Direct Object References (IDOR).
During the indexing process, Gecko enhances its index with contextual information by identifying features like endpoints and assigning attributes to each node, including request paths, HTTP methods, and authentication mechanisms. This allows us to map all producers and consumers and their relationships together, which means we can generate accurate proof-of-concepts with sequential curl commands and identify missing authentication on critical endpoints.
During this phase, Gecko identified four exposed endpoints in the API v1 that used underscore-prefixed files (_get.ts, _post.ts, _patch.ts, _delete.ts) as internal route handlers. The main index.ts entry point properly applied authorization middleware before calling these handlers. However, Next.js exposed these underscore files as directly accessible routes. Accessing these routes directly bypassed all authorization checks.

![Cal.com Bookings Endpoint Graph showing /bookings/[id]/_delete](/_next/image?url=https%3A%2F%2Fcdn.sanity.io%2Fimages%2Fm1rmw7nk%2Fproduction%2Fea4f505e536261e44d4603adb6b37d36b73a0a69-1210x887.png%3Fauto%3Dformat&w=1920&q=75)
This allowed any authenticated user with a valid v1 API key to read and delete all bookings across the entire platform, exposing:
The same pattern affected destination calendar endpoints, allowing any authenticated user to delete any user’s destination calendar by ID, breaking calendar routing rules silently.
Cal.com’s fix updated the Next.js middleware to block direct access to internal route handlers (/_get, /_post, /_patch, /_delete, /_auth-middleware), returning 403 Forbidden for any request attempting to access these paths directly.
This research highlights how broken access control issues exist in virtually every application, and how several subtle bugs in core components can chain together to dismantle security boundaries.
The impact of these vulnerabilities resulted in complete account takeover of any user on Cal.com, including admin accounts and paid users, and exposed all sensitive booking data including PII.
Defense in depth matters. While each individual vulnerability might seem minor in isolation, chaining them together had a significant impact. This demonstrates why layered security is crucial.
The goal now is to democratize this same speed to everyone. Gecko wants to bring this level of automated, AI-assisted detection and validation into security teams’ toolkits, so defenders can find and remediate complex chained issues before attackers can stitch them together.
Book a call with us if you’d like to learn more about Gecko and how we can help you find and fix vulnerabilities in your software.

Jeevan “JJ” Jutla
Co-founder & CEO
JJ joined GCHQ as a teenager, working on security research and exploit development, and scored the highest mark on its reverse engineering and binary exploitation aptitude test ever recorded. The record still stands. He went on to lead security tool development for Binance’s eight-person red team and led the recovery of $500K stolen by North Korean state hackers, the largest recovery of stolen funds at the time.
The latest news, technologies, and resources from our team.
How Cal.com consolidated noisy security tooling into one continuous, context-aware pull request security program with Gecko.
Gecko Security
Authorization bypass in n8n’s dynamic-credentials OAuth endpoints allows any authenticated user to operate on another user’s OAuth credential by supplying its ID, enabling unauthorized OAuth rebinding and revocation.
Artemiy Malyshau
An IDOR vulnerability in n8n’s public variables API allows authenticated users to read project variables outside their authorized scope, exposing secrets across project boundaries.
Artemiy Malyshau
Learn API scanning for automated security testing. Find vulnerabilities from broken authentication to business logic flaws in your endpoints.
Artemiy Malyshau
A complete guide to automated pentest tools and best practices. Learn what works, what doesn’t, and how to implement continuous security testing.
Artemiy Malyshau
Compare the best AI-powered application security testing tools. Find which tools detect business logic flaws and broken access control.
Artemiy Malyshau
Occasional updates, new content, and insights. No spam; unsubscribe anytime.