{"id":480,"date":"2026-10-04T16:31:44","date_gmt":"2026-10-04T16:31:44","guid":{"rendered":"https:\/\/marcinzygmunt.pl\/blog\/?p=480"},"modified":"2026-10-04T17:00:31","modified_gmt":"2026-10-04T17:00:31","slug":"you-can-build-an-app-in-a-few-hours-someone-will-find-its-holes-in-a-few-minutes","status":"publish","type":"post","link":"https:\/\/marcinzygmunt.pl\/blog\/you-can-build-an-app-in-a-few-hours-someone-will-find-its-holes-in-a-few-minutes\/","title":{"rendered":"You Can Build an App in a Few Hours. Someone Will Find Its Holes in a Few Minutes."},"content":{"rendered":"\n<p class=\"wp-block-paragraph\">An app built by AI in a few hours can look great, work perfectly, and still hand other people&#8217;s data to the first person who goes looking.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">I&#8217;m not against vibe coding. I use AI at work every day. A model can build in a few hours what used to take a whole team. That still doesn&#8217;t remove the responsibility for the security of what gets built.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A few days ago, a friend asked me to check an app he&#8217;d built entirely with AI. It collected customer data, had authentication from a backend-as-a-service and was deployed on a popular hosting platform. It looked great, and the business features worked flawlessly. I asked for one thing: the link.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first test showed nothing. Then it turned out that a plain account signup, not even approved by an admin, opened the way to every customer&#8217;s data. Next came privilege escalation and uploading files with scripts. The holes were fixed before the app reached users. But how many apps like this reach customers with no review at all?<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">It&#8217;s not a one-off<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The scale is bigger than you might expect:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong><a href=\"https:\/\/www.symbioticsec.ai\/blog\/we-scanned-1-072-vibe-coded-apps-98-had-security-flaws\">Symbiotic Security<\/a><\/strong> scanned 1,072 apps from vibe-coding platforms, all running on Supabase. 98% had at least one hole, even a minor one, and 16% had a critical one. In 39 apps anyone with the public key could read entire tables, and in 172 records could be deleted without logging in.<\/li>\n\n\n\n<li><strong><a href=\"https:\/\/www.veracode.com\/blog\/spring-2026-genai-code-security\/\">Veracode<\/a><\/strong> tested more than 150 models on four classes of bugs (including SQL injection and XSS). Only about 55% of the code came out free of vulnerabilities.<\/li>\n\n\n\n<li><strong>Matt Palmer<\/strong> <a href=\"https:\/\/mattpalmer.io\/posts\/statement-on-CVE-2025-48757\/\">analyzed 1,645 Lovable projects<\/a> and found vulnerable endpoints in 170 of them (303 in total). That became <a href=\"https:\/\/nvd.nist.gov\/vuln\/detail\/CVE-2025-48757\">CVE-2025-48757<\/a> (CVSS 9.3 according to MITRE). The vendor disputes it, arguing that customers are responsible for protecting their apps&#8217; data.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Symbiotic and Palmer looked at finished apps, Veracode at code from models, and all three cover only AI-generated code. I&#8217;m not claiming AI writes less secure code than people do, because this data can&#8217;t show that. What it does show is how often common mistakes reach production.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">You&#8217;re not a target because you&#8217;re important. You&#8217;re a target because you&#8217;re easy. Scanners look on their own.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Why it slips through<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">The model does what you ask: you want a working app and you get one. Asking about security is easy to skip, especially when a rushed version has to ship as fast and as cheaply as possible. And even an authorization flow that seems obvious can have holes, while the security of your hosting and BaaS has to be deliberately demanded and configured.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">What these holes look like in code<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Three typical patterns, each from a different vendor. The first two are the same kind of mistake I found in that app, the third is a slip of a different kind. File upload comes back in the prompts at the end. These are general examples, not a description of that particular app. All of them are simple, all of them &#8220;work&#8221;, and none of them breaks anything in a demo.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">1. AWS Cognito: a role users set themselves<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Cognito lets you add custom user attributes, e.g. <code>custom:role<\/code>. Attributes readable by the app end up in the ID token as claims. <a href=\"https:\/\/docs.aws.amazon.com\/cognito\/latest\/developerguide\/user-pool-settings-attributes.html\">The AWS docs<\/a> show how this becomes a hole. A mutable attribute can be changed by the user if the app client has write access to it. A new app client gets read and write access to all attributes by default, and an attribute added later has to be granted access explicitly.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If the backend trusts <code>custom:role<\/code> from the token, an ordinary user can set their own role to admin with one call:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import {\n  CognitoIdentityProviderClient,\n  UpdateUserAttributesCommand,\n} from '@aws-sdk\/client-cognito-identity-provider'\n\n\/\/ only on your own user pool\n\/\/ an ordinary user, their own access token (needs the aws.cognito.signin.user.admin scope)\nawait new CognitoIdentityProviderClient({ region: 'eu-central-1' }).send(\n  new UpdateUserAttributesCommand({\n    AccessToken: accessToken,\n    UserAttributes: &#91;{ Name: 'custom:role', Value: 'admin' }],\n  })\n)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Marking the attribute as immutable is not enough: the docs describe that such an attribute can be set once at signup, if the app client has write access to it. What to do:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>remove the app client&#8217;s write access to the role attribute (<code>WriteAttributes<\/code>),<\/li>\n\n\n\n<li>set the role server-side only (<code>AdminUpdateUserAttributes<\/code> or <code>AdminCreateUser<\/code>).<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A separate issue, but in the same place: verify the token in the backend, don&#8217;t just decode it. <a href=\"https:\/\/github.com\/auth0\/node-jsonwebtoken\">The jsonwebtoken docs<\/a> say plainly that <code>jwt.decode<\/code> doesn&#8217;t check the signature and must not be used on untrusted data.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The same pattern, a user-editable field used for authorization, is called <code>user_metadata<\/code> in Supabase. <a href=\"https:\/\/supabase.com\/docs\/guides\/database\/postgres\/row-level-security\">The docs<\/a> warn that it&#8217;s not a good place to store authorization data.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">2. Supabase: a policy that says &#8220;yes&#8221; to everyone<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Row Level Security (RLS) enabled with an &#8220;allow everyone&#8221; policy gives you a false sense of security. A <code>using (true)<\/code> policy for the <code>anon<\/code> role hands every unauthenticated visitor all the rows that role has privileges on. Even <code>using (true)<\/code> for the <code>authenticated<\/code> role alone is enough: if anyone can create an account, signup becomes a door to other people&#8217;s data. And a table in an exposed schema without RLS is readable and writable by any role that has privileges on it.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>-- vulnerable: anyone, even logged out, reads the whole table\ncreate policy \"read\" on customers\n  for select to anon, authenticated using (true);\n\n-- better: a user sees only their own rows\ncreate policy \"own rows\" on customers\n  for select to authenticated\n  using ((select auth.uid()) = owner_id);<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">Two more traps from the same docs. Views bypass RLS by default, so on Postgres 15 and newer you create them with <code>security_invoker = true<\/code>. And the secret key (or the older JWT-based <code>service_role<\/code> key) bypasses all policies, so it must never reach the browser.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The public key sits in the JS bundle, so an attacker has it from the first second. You can check in a minute what it allows on your project:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>\/\/ only on your own project, logged out, with the public key alone\nimport { createClient } from '@supabase\/supabase-js'\n\nconst supabase = createClient('https:\/\/&lt;PROJECT&gt;.supabase.co', '&lt;PUBLIC_KEY&gt;')\nconst { data } = await supabase.from('customers').select('*').limit(5)\nconsole.log(data)<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">If <code>data<\/code> contains rows and nobody logged in, you have a problem.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The first two patterns are the kind of mistake that the <a href=\"https:\/\/api-security.owasp.org\/editions\/2023\/en\/0x11-t10\">OWASP API Security Top 10 (2023)<\/a> lists, among them Broken Object Level Authorization, Broken Authentication and Broken Function Level Authorization.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">3. Next.js: a secret in browser code<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">In Next.js, an environment variable with the <code>NEXT_PUBLIC_<\/code> prefix is inlined at build time into the JavaScript sent to the browser. Variables without the prefix stay on the server. <a href=\"https:\/\/nextjs.org\/docs\/app\/guides\/environment-variables\">That&#8217;s how the docs describe it<\/a>. So adding the prefix to a paid API key is all it takes for every visitor to read it in the browser&#8217;s dev tools and use it at your expense.<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code># vulnerable: the key ends up in the JS bundle\nNEXT_PUBLIC_OPENAI_API_KEY=sk-...\n\n# better: no prefix, used only on the server (e.g. in a Route Handler)\nOPENAI_API_KEY=sk-...<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">A public key in the bundle, such as an analytics ID, is normal practice. A secret isn&#8217;t. One more thing: the value is baked into every bundle already built, so after a leak, removing the prefix isn&#8217;t enough. You have to revoke the key and generate a new one.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Ask the AI before someone else does<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">AI can also secure code, but you need to know where the weak points are and ask about them. Below are six short prompts I&#8217;d start a review with. You give the model the repository (SQL migrations, backend, frontend) and ask for something specific, not a general &#8220;check the security&#8221;. Every time, require the file and line, an attack scenario and a fix, and make it say outright what it can&#8217;t judge from the code. That cuts down on guessing.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>1. An attack by an ordinary user<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Act as a pentester. Assume the attacker has an ordinary user account and knows the public key from the JS bundle. Find the 10 most likely ways to read or modify other people's data. For each, give: file and line, a step-by-step attack scenario, severity (critical\/high\/medium) and a fix. If something can't be judged from the code (e.g. dashboard settings), say so explicitly.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>2. Data access<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Review the data access configuration: RLS policies in SQL migrations, Firestore rules, IAM and bucket policies, app client permissions in Cognito. List in a table every resource and who (unauthenticated, authenticated, admin) can read, create, update and delete it. Flag \"allow everyone\" rules (e.g. using (true)), writes without an ownership check, permissions based on fields the user can edit themselves, views that bypass policies, and resources with no rule at all.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>3. Tokens and permissions<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Find every place in the backend where a role, user id or permission comes from a JWT or from the request body or query. Check whether the token is verified (signature, algorithm, expiry) and not just decoded. For each endpoint answer: who can call it, and does the code check that the resource belongs to the logged-in user.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>4. Secrets in browser code<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Search the repository and frontend code for secrets: provider API keys, passwords, tokens, secret or service_role keys, .env files in git history. Watch for variables with a prefix that inlines them into browser code (e.g. NEXT_PUBLIC_). For each finding, say what an attacker could do with that key.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>5. Login and limits<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>List the login, signup, password reset and OTP endpoints. For each, check: attempt limits (per IP and per account), lockout or increasing delay, differences in error messages that reveal whether an account exists, and whether they can be called in bulk. Propose concrete fixes for our stack.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>6. File upload<\/strong><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>Review file upload handling: type validation (by content, not extension), size and name, where files are stored, who can upload and read them, and whether files are served from the same domain as the app. Describe how to upload a file with a script and where it would execute.<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">What these prompts don&#8217;t do. They&#8217;re no substitute for an audit or a pentest: a model can report a bug that isn&#8217;t there and miss one that is. Confirm every finding yourself, for example with a two-account test (does user A see user B&#8217;s data?) or a query with the public key alone. Run them in a fresh session, because fresh context doesn&#8217;t know the assumptions the code was built on.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Deploy isn&#8217;t the end<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">After deploy come log monitoring and regular scanning. AI takes the writing of code off your plate. Responsibility for other people&#8217;s data stays with you, and deploy doesn&#8217;t end it.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Let&#8217;s talk<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">If your app holds data and you don&#8217;t know where to start or what to look at, get in touch.<\/p>\n\n\n<div class=\"wp-block-jetpack-contact-form\"><a href=\"https:\/\/marcinzygmunt.pl\/blog\/you-can-build-an-app-in-a-few-hours-someone-will-find-its-holes-in-a-few-minutes\/\" target=\"_blank\" rel=\"noopener noreferrer\">Submit a form.<\/a><\/div>\n\n\n<h2 class=\"wp-block-heading\">Links &amp; sources<\/h2>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/www.symbioticsec.ai\/blog\/we-scanned-1-072-vibe-coded-apps-98-had-security-flaws\">Symbiotic Security: 1,072 vibe-coded apps scanned<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/www.veracode.com\/blog\/spring-2026-genai-code-security\/\">Veracode: Spring 2026 GenAI Code Security<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/mattpalmer.io\/posts\/statement-on-CVE-2025-48757\/\">Matt Palmer: statement on CVE-2025-48757 (1,645 projects, vulnerable endpoints in 170)<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/nvd.nist.gov\/vuln\/detail\/CVE-2025-48757\">CVE-2025-48757 in NVD (description, MITRE score, vendor dispute note)<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/supabase.com\/docs\/guides\/database\/postgres\/row-level-security\">Supabase: Row Level Security (user_metadata and app_metadata, views, secret key)<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/docs.aws.amazon.com\/cognito\/latest\/developerguide\/user-pool-settings-attributes.html\">AWS: Amazon Cognito user attributes and app client permissions<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/nextjs.org\/docs\/app\/guides\/environment-variables\">Next.js: environment variables (the NEXT_PUBLIC_ prefix)<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/api-security.owasp.org\/editions\/2023\/en\/0x11-t10\">OWASP API Security Top 10 (2023)<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/github.com\/auth0\/node-jsonwebtoken\">jsonwebtoken README (jwt.decode vs jwt.verify)<\/a><\/li>\n<\/ul>\n","protected":false},"excerpt":{"rendered":"<p>An app built by AI can look perfect and still leak customer data. Three typical holes in code, what the scans found, and six prompts to review your own app.<\/p>\n","protected":false},"author":1,"featured_media":488,"comment_status":"open","ping_status":"open","sticky":true,"template":"","format":"standard","meta":{"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[1],"tags":[13,132,77,9,144,141],"class_list":["post-480","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized","tag-ai","tag-application-security","tag-cybersecurity","tag-jwt","tag-prompt-engineering","tag-vibe-coding"],"jetpack_likes_enabled":true,"jetpack_sharing_enabled":true,"jetpack_featured_media_url":"https:\/\/marcinzygmunt.pl\/blog\/wp-content\/uploads\/2026\/09\/vibe-coding-security-cover-v2.png","_links":{"self":[{"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/posts\/480","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/comments?post=480"}],"version-history":[{"count":13,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/posts\/480\/revisions"}],"predecessor-version":[{"id":515,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/posts\/480\/revisions\/515"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/media\/488"}],"wp:attachment":[{"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/media?parent=480"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/categories?post=480"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/marcinzygmunt.pl\/blog\/wp-json\/wp\/v2\/tags?post=480"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}