Comprehensive Security Audit
This assessment of acme-saas.app found 17 vulnerabilities (5 critical, 5 high, 4 medium, 2 low). Overall risk rating: CRITICAL.
For a plain-language breakdown, see The Big Picture.
| Priority | Timeframe | Action |
|---|---|---|
| P1 | Immediate | EMERGENCY: Enable RLS on ALL tables immediately. As an interim measure, consider enabling RLS with a deny-all policy on the most sensitive tables (pro... |
| P2 | Immediate | Enable RLS on profiles immediately. Public profile fields (name, avatar, slug) should have a separate public-facing view. Sensitive fields (is_admin, ... |
| P3 | Immediate | RLS policy: messages should only be readable by the chat owner. CREATE POLICY messages_owner_only ON messages FOR SELECT USING (chat_id IN (SELECT id ... |
| P4 | Immediate | IMMEDIATELY rotate this Figma API token. Remove it from the client bundle entirely. Implement Figma API access through a server-side edge function tha... |
| P5 | Immediate | Implement RLS policy: CREATE POLICY blocked_ips_admin_only ON blocked_ips FOR SELECT USING (auth.uid() IN (SELECT id FROM profiles WHERE role = 'admin... |
Here's the bottom line: acme-saas.app has serious security problems that need fixing right now. We found issues in 5 areas, including 5 critical-severity findings. The sections below break down what's wrong in plain language.
9 database tables have no access controls. That means 5.2M+ records are readable by anyone on the internet — no login required.
Personal and sensitive user data is exposed including names and profile data, payment information, IP addresses, private messages. This is the kind of data that triggers mandatory breach notifications under GDPR.
Figma, Unsplash credentials are hardcoded in the client-side JavaScript bundle. Anyone who views the page source can extract them and make authenticated API calls.
Security controls like role checks and function permissions have gaps. This can let regular users perform admin actions or access data they shouldn't see.
Internal details like staging URLs, API configurations, and analytics data are visible to the public. Attackers use this kind of information to plan more targeted attacks.
| Severity | Count | Percentage |
|---|---|---|
| Critical | 5 | 29% |
| High | 5 | 29% |
| Medium | 4 | 24% |
| Low | 2 | 12% |
| Info | 1 | 6% |
| Total | 17 | 100% |
| Category | Findings | Risk Level |
|---|---|---|
| Mass Data Exposure | 1 | MEDIUM |
| Pii Exposure | 2 | HIGH |
| Hardcoded Api Key | 2 | HIGH |
| Sensitive Data Exposure | 1 | MEDIUM |
| Data Exposure | 2 | HIGH |
| Missing Rls Policy | 2 | HIGH |
| Security Misconfiguration | 2 | HIGH |
| Access Control | 1 | MEDIUM |
| Information Disclosure | 4 | CRITICAL |
Anyone on the internet can read 4.8M+ records from your database without logging in.
Probed all 27 discovered tables. 9 additional tables beyond the original 3 have NO RLS and return data to unauthenticated requests: profiles (119,709 records), messages (1,637,674 records), chats (320,869 records), shared_code (15,605 records), components (2,146 records), file_attachments (276,423 records), changelogs (31 records), skills (8 records), code_snippets (8 records). Total: ~2,434,473 records exposed.
CRITICAL DATA BREACH RISK. The entire application database is effectively public. Most severe exposures: (1) profiles table: 119K user records with full_name, avatar_url, is_admin flag, stripe_customer_id, stripe_subscription_id, subscription_status, is_banned, is_tester flags, daily/monthly usage counters. (2) messages table: 1.6M+ AI chat messages containing full user conversations with the AI including generated HTML/code - this is private user content. (3) chats table: 320K chat sessions with user_id linkage. (4) file_attachments: 276K files with direct download URLs to user-uploaded content. (5) shared_code: 15K templates with full HTML source code, user_id, custom_domain info, DNS verification status. An attacker can trivially: enumerate all users, identify admins (is_admin=true), read all private conversations, download all user files, and map the entire user base with their subscription status.
EMERGENCY: Enable RLS on ALL tables immediately. As an interim measure, consider enabling RLS with a deny-all policy on the most sensitive tables (profiles, messages, chats, file_attachments) and then adding specific allow policies. This is a P0 incident.
User names, emails, payment details, profile photos is exposed to anyone who knows where to look.
profiles table returns: id, full_name, bio, avatar_url (Google profile photos), pro_subscription, daily_prompt_usage, monthly_standard_prompt_usage, monthly_premium_prompt_usage, subscription_tier, is_admin, stripe_customer_id, stripe_subscription_id, subscription_status, subscription_current_period_end, cancellation_comment, cancellation_feedback, cancellation_reason, slug, views, is_featured, is_tester, is_banned, email (column exists but null in sample), website, location. Sample: 'John D.' with Google avatar URL.
Complete user directory with PII (names, photos, locations), admin enumeration (is_admin field), payment information (Stripe customer/subscription IDs allow lookup in Stripe), subscription intelligence (who pays, what tier, when they cancel and why), and behavioral data (usage counters). GDPR Article 5 violation - personal data must be processed lawfully with appropriate security.
Enable RLS on profiles immediately. Public profile fields (name, avatar, slug) should have a separate public-facing view. Sensitive fields (is_admin, stripe IDs, usage data, cancellation feedback, is_banned) must NEVER be publicly accessible.
1.6M+ records containing private messages are publicly accessible.
messages table: 1,637,674 records. Columns include: id, content (full message text), is_user, is_system, is_streaming, model (AI model used), timestamp, chat_id, ip. Sample message contains full AI-generated HTML code for a user's project.
Every conversation every user has ever had with the platform's AI is publicly readable. This includes: proprietary designs and code users asked the AI to generate, potentially confidential business information shared in prompts, user IP addresses (ip column), and the full creative output. This is equivalent to making every user's private workspace public. Massive GDPR, privacy, and intellectual property liability.
RLS policy: messages should only be readable by the chat owner. CREATE POLICY messages_owner_only ON messages FOR SELECT USING (chat_id IN (SELECT id FROM chats WHERE user_id = auth.uid())).
A secret key for Figma is embedded in the public JavaScript. Anyone can extract it and use it.
const r0r={figmaApiKey:"figd_****************************"}. Used as fallback in getEffectiveToken(): returns this.getAccessToken()||r0r.figmaApiKey. Calls api.figma.com/v1/files/{id}/nodes with this token.
CRITICAL - A Figma personal access token (figd_ prefix) is hardcoded in the client bundle as a fallback when no user-provided token exists. Anyone can extract this token and use it to: (1) Read all files in the associated Figma account, (2) Access design assets, (3) Potentially modify files depending on token scope, (4) Access team/org data. This is a direct credential exposure granting authenticated API access to a third-party service.
IMMEDIATELY rotate this Figma API token. Remove it from the client bundle entirely. Implement Figma API access through a server-side edge function that proxies requests with proper user authentication. Never use personal access tokens as fallbacks in client code.
67 records of sensitive data can be read without authorization.
67 records accessible without authentication. Sample: ip_address=203.0.113.10, ip_address=203.0.113.25. All 6 columns exposed: id, ip_address, blocked_by (user UUID), reason, created_at, updated_at.
IP addresses are considered PII under GDPR (EU regulation). The blocked_ips table leaks: (1) IP addresses of blocked users - potentially identifying individuals, (2) UUIDs of admins who performed the blocking via blocked_by field, (3) Timing information of moderation actions. An attacker can enumerate all blocked IPs and correlate admin UUIDs across tables.
Implement RLS policy: CREATE POLICY blocked_ips_admin_only ON blocked_ips FOR SELECT USING (auth.uid() IN (SELECT id FROM profiles WHERE role = 'admin')). This table should never be publicly readable.
276K+ records are accessible to unauthenticated users.
file_attachments table: 276,423 records with file_name, file_url (direct Supabase storage URLs), thumbnail_url, mime_type, file_size, message_id. Sample: hub_app.py at https://xxxxxxxxxxxx.supabase.co/storage/v1/object/public/attachments/...
All user-uploaded files (images, code, documents) are enumerable and downloadable. The storage URLs use the 'public' bucket path, meaning even if the table gets RLS, the actual files remain downloadable if someone has the URL. Combined with the messages table exposure, attackers can correlate files to specific users and conversations.
Enable RLS on file_attachments. Move the attachments storage bucket from public to private. Use signed URLs with short expiry for file access.
16K+ records are accessible to unauthenticated users.
shared_code table: 15,605 records. Columns: id, slug, title, code (FULL HTML source), language, username, created_at, views, image_url, forks, private, watermark, featured, share_source_code, user_id, category, custom_domain, favicon URLs, DNS verification fields. Records include private=false AND private=true entries.
All user-created templates/shared code is accessible regardless of privacy setting. This includes: full HTML/CSS/JS source code, custom domain configurations with DNS verification status, user attribution. The 'private' flag is meaningless without RLS - same issue as the assets table. This is likely the 'templates' you were expecting to find.
Enable RLS: public templates WHERE private=false, owner-only WHERE private=true. This is the template/shared code system you mentioned.
A database table with 57K+ records has no row-level security, so anyone can read everything in it.
56,904 records accessible without authentication. Filter abuse confirmed (gt operator). All 23 columns exposed including: created_by (user UUIDs), embedding vectors, private flag, premium flag.
Major data exposure: (1) Private assets (private=true) are readable by anyone - the 'private' flag is meaningless without RLS, (2) created_by field leaks user UUIDs that can be cross-referenced, (3) Full embedding vectors exposed which could be used to reverse-engineer content, (4) Premium content metadata accessible without subscription. Filter abuse allows full data extraction of all 56,904 records.
Implement RLS policies: (1) Public assets: CREATE POLICY assets_public_read ON assets FOR SELECT USING (private = false AND active = true); (2) Private assets: CREATE POLICY assets_owner_read ON assets FOR SELECT USING (auth.uid() = created_by); (3) Consider excluding embedding column from default select.
A security setting is misconfigured, which could let attackers bypass intended restrictions.
RPC function get_user_emails called in multiple locations to fetch user email addresses. Used in admin search, user management, and profile lookup. Returns objects with {id, email} fields.
If this RPC function does not have proper security definer/invoker controls, any authenticated user could call it to enumerate all user email addresses in the system. This would be a serious PII exposure enabling: phishing attacks, spam, account enumeration, and GDPR violations.
Verify that get_user_emails RPC has SECURITY DEFINER with role checks (admin only). Add explicit auth.uid() checks within the function. Consider replacing with server-side-only calls via edge functions.
Access controls have a gap that could allow unauthorized actions.
Sample data shows assets with private=true are returned in unauthenticated queries. Example: asset ID 54336, title='Red 3D Triangle Symbol Over Snow Landscape', private=true, created_by=d056342e-xxxx-xxxx-xxxx-xxxxxxxxxxxx.
The 'private' flag on assets is purely cosmetic without RLS enforcement. Any user (or unauthenticated request) can read all assets regardless of the private flag, completely bypassing the intended access control. Users who marked assets as private have a false expectation of confidentiality.
This is urgent. Implement RLS immediately so that private assets are only visible to their creators. The private flag must be enforced at the database level, not just the application layer.
A secret key for Unsplash is embedded in the public JavaScript. Anyone can extract it and use it.
const qt="****************************" used with Unsplash API via Client-ID header
Unsplash API key exposed in client-side JavaScript. Can be used to make API requests on behalf of the application, potentially exhausting rate limits (50 req/hr for demo, 5000/hr for production). Attacker could abuse the key for their own image searches.
Move Unsplash API calls to a server-side edge function. Rotate the current key. If client-side access is required, implement a proxy endpoint that rate-limits per user.
A database table with 16 records has no row-level security, so anyone can read everything in it.
16 records accessible. Contains download_url with long-lived signed JWT tokens for Supabase Storage (exp: 2061). Filter abuse confirmed.
App update records contain signed storage URLs with tokens that expire in ~35 years (exp:2061000485). While the app_updates content itself is semi-public (release notes), the signed URLs are effectively permanent download links. This is likely intentional for a public changelog, but the long-lived tokens are a concern if any update contains sensitive builds.
If this is intentionally public, add explicit RLS: CREATE POLICY app_updates_public_read ON app_updates FOR SELECT USING (true). Use shorter-lived signed URLs (hours, not decades). Verify no pre-release or internal builds are accessible.
Internal information is leaking that could help an attacker plan a more targeted attack.
window.location.hostname==="beta--acme-saas-app.netlify.app" with auth callback URLs. Also contains localhost:8080/auth/callback and localhost:9999 references.
Beta environment hostname disclosed. Attackers can target the staging environment which may have weaker security controls, test data, or debug features enabled. The localhost references suggest development configuration leaked into production.
Remove staging/development URL references from production builds using environment-specific build configurations. Use build-time environment variables to conditionally include these URLs.
A security setting is misconfigured, which could let attackers bypass intended restrictions.
The Supabase anon key appears 14 times directly in fetch() calls to edge functions, rather than using the Supabase client library.
While the anon key is designed to be public, hardcoding it directly in fetch calls (rather than using createClient) bypasses the Supabase client's built-in auth token refresh and error handling. This pattern suggests some edge function calls may not properly validate the user's JWT on the server side.
Use the Supabase client's functions.invoke() method instead of raw fetch with hardcoded keys. Ensure all edge functions validate the Authorization bearer token server-side.
Internal information is leaking that could help an attacker plan a more targeted attack.
Umami: websiteId=d0e07933-xxxx-xxxx-xxxx-xxxxxxxxxxxx, dashboardUrl=https://cloud.umami.is/share/REDACTED/acme-saas.app. Google Analytics: G-XXXXXXXXXX, GT-XXXXXXXXX, GT-XXXXXXXXX.
The Umami share URL provides public access to analytics data including page views, visitor counts, referrers, and user behavior patterns. This gives competitors insight into traffic volumes and user engagement. Google Analytics IDs are standard but confirm the analytics stack.
Disable the public Umami share link if analytics data is sensitive. Review what data is visible in the shared dashboard.
Internal information is leaking that could help an attacker plan a more targeted attack.
15 unique edge functions discovered (add-custom-domain, auto-fill-asset-metadata, continue-generation, describe-image, generate-component-metadata, generate-components, generate-edits, generate-html, generate-image, html-to-component, iterate-react-component, react-generator, remove-background-replicate, upscale-image, plus 8 Stripe/newsletter functions). 26 unique RPC functions discovered. 6 storage buckets referenced (assets, components, user-avatars, attachments, preview-images, changelog-images, app-updates).
Complete API surface map available to attackers from a single JavaScript bundle. This enables targeted attacks against specific endpoints. The newsletter schema reveals a separate Supabase schema, and the pgmq_delete RPC reveals use of a message queue system.
Code-split admin-only functionality into separate bundles loaded only for admin users. Consider server-side rendering for admin features to avoid exposing the full API surface to all users.
Internal information is leaking that could help an attacker plan a more targeted attack.
blocked_ips: 67 records, app_updates: 16 records, assets: 56,904 records. Counts exposed via Supabase Content-Range header.
Exact record counts reveal business metrics: total assets (56K+), blocked users (67), and app versions (16). Competitors can track growth by periodically checking these counts.
For tables with RLS, Supabase automatically hides counts. Implementing RLS (as recommended above) will resolve this. For intentionally public tables, consider if count exposure is acceptable.
This audit was performed using a combination of automated scanning and AI-driven analysis:
| RLS | Row Level Security — PostgreSQL feature to restrict row access based on policies |
| PII | Personally Identifiable Information — data that can identify an individual |
| Anon Key | Supabase anonymous API key for unauthenticated public access |
| CVSS | Common Vulnerability Scoring System |
| CWE | Common Weakness Enumeration |
| GDPR | General Data Protection Regulation (EU) |
| SOC 2 | Service Organization Control Type 2 |
| PCI-DSS | Payment Card Industry Data Security Standard |
| RPC | Remote Procedure Call — server-side functions callable via API |