Skip to content
LockShip

Sample report · example only

What you get for $39, shown on an app we broke on purpose

When we look at any security tool, the first thing we want to know is what we’d actually get for the money. A landing page can’t really tell you that, so here’s the whole thing, start to finish: the free summary, the paid report, the fixes, the re-scan, and the certificate at the end.

The cards use the same components as a real report, so this is what yours will look like. The grey boxes between them are our notes for this page. Your report won’t have those, but every finding in it comes with its own plain-English summary and a fix prompt.

1. Before you pay: the free summary

This is everything the free scan shows. A score out of 100, how many findings landed at each level, and the worst one in plain words. We show the worst one for free on purpose. If there’s a live key in your JavaScript, you should know today, whether you buy anything from us or not.

Custom app

24

out of 100 · needs attention before launch

  • critical1
  • high1
  • medium7
  • low2
criticalOpenAI API key in client code

An OpenAI API key is in JavaScript a stranger can download. They can spend your API credit until you revoke the key.

10 more findings are in the full report, with the proof and the fix prompt.

How the score works: everyone starts at 100. Each critical finding costs 25 points, a high 12, a medium 5 and a low 2. A score is a rough signal, not the verdict. The verdict is simpler: any critical or high finding means the app isn’t ready, and that’s also the rule the badge uses.

2. After you pay: the full report

Paying $39 opens every finding. Each card has what a stranger can see, with values redacted, and a fix prompt you can paste into Lovable, Bolt, v0, Replit or Cursor. Because this test app has no builder signature, its prompts use the generic wording. Yours will name your builder if we can tell which one you used.

criticalOpenAI API key in client code

What it means
The app calls OpenAI straight from the browser, so the key has to travel with the page. In this test app it sat in a NEXT_PUBLIC_ variable, and Next.js copies anything with that prefix into the JavaScript it sends to every visitor. Anyone who opens the browser’s developer tools can read it.
Why it matters
Whoever has the key can spend your OpenAI credit, and it keeps working until you revoke it. Nobody needs to be clever for this one. They copy a string. This is the one we’d want to hear about on day one, which is why the free summary shows it.
What we’d do
Revoke the key first, before you touch any code, because the old value is already out. Then move the call into a server route and give the new key a name without NEXT_PUBLIC_ or VITE_. Notice the proof only shows the first and last few characters. We never store the whole key.
criticalOpenAI API key in client code

An OpenAI API key is in JavaScript a stranger can download. They can spend your API credit until you revoke the key.

What a stranger can see

Found in a script the page loads (/_next/static/chunks/app/page-98db774a11f3f8e4.js). Fingerprint: sk-proj…cdef. The full value was not stored.

Fix prompt

OpenAI API key in client code. Rotate it before you do anything else. A scanner finding is not what makes the key public — the bundle already is.

Revoke the key on the OpenAI API keys page and create a new one.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Remove this secret from client code. Put it in a server environment variable with no NEXT_PUBLIC_ or VITE_ prefix. Add a server route that performs the privileged call, and rotate the leaked key so the old value stops working."

highUnauthenticated response from /api/notes

What it means
/api/notes handed back every note in the app to a request that had no login at all. Our scanner found that address inside the app’s own JavaScript and asked for it once, the way a curious stranger would.
Why it matters
Here it’s two fake notes from “alice” and “bob”. In a real app it would be your users’ data, and the address is sitting in your JavaScript for anyone to find.
What we’d do
Check for a session at the top of the route and send back a 401 when there isn’t one. Then make sure the query only returns rows that belong to the person asking. The proof shows the shape of the response, never the values.
highUnauthenticated response from /api/notes

/api/notes returned JSON that looks like user or billing data, and LockShip did not send credentials.

What a stranger can see

GET /api/notes returned 200. Body redacted to its shape, not its values.

Fix prompt

An API or edge route returned data without a login.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"This route is public. Require a session at the top of the handler and return 401 when it is missing. Do not trust a user id sent from the browser."

mediumNo Content-Security-Policy

What it means
The page came back with no Content-Security-Policy, so the browser has no rule about which scripts are allowed to run.
Why it matters
On its own this isn’t a hole. It’s the seatbelt. If something else goes wrong and a stranger’s script gets onto the page, a policy limits what that script can reach.
What we’d do
The fix prompt includes a header block you can paste. Load your pages after you add it, because a strict policy can block a script you actually need. On the fixed copy of this app we put the headers in next.config.
mediumNo Content-Security-Policy

Without a content security policy, a single injected script can read data your users can see.

What a stranger can see

The homepage response did not include a Content-Security-Policy header.

Fix prompt

A recommended browser security header is missing.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Add the security headers in this vercel.json snippet."

Add this to vercel.json (or the matching header settings on Netlify, Firebase Hosting, or your reverse proxy):
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; frame-ancestors 'none'"
        },
        {
          "key": "Strict-Transport-Security",
          "value": "max-age=63072000; includeSubDomains; preload"
        },
        {
          "key": "X-Frame-Options",
          "value": "DENY"
        },
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        },
        {
          "key": "Referrer-Policy",
          "value": "strict-origin-when-cross-origin"
        }
      ]
    }
  ]
}

mediumPage can be framed

What it means
Nothing tells the browser to refuse when another site tries to load this app inside a frame.
Why it matters
That’s what clickjacking needs: someone shows your real page under their own buttons and gets your users to click things they can’t see. It matters most on pages that take an action, like settings or payments.
What we’d do
One header fixes it: X-Frame-Options: DENY, or frame-ancestors 'none' inside the CSP. It’s in the same header block.
mediumPage can be framed

The site does not set X-Frame-Options or CSP frame-ancestors, so another site can embed it and trick clicks.

What a stranger can see

Neither X-Frame-Options nor a CSP frame-ancestors directive was present.

Fix prompt

A recommended browser security header is missing.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Add the security headers in this vercel.json snippet."

Add this to vercel.json (or the matching header settings on Netlify, Firebase Hosting, or your reverse proxy):
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; frame-ancestors 'none'"
        },
        {
          "key": "Strict-Transport-Security",
          "value": "max-age=63072000; includeSubDomains; preload"
        },
        {
          "key": "X-Frame-Options",
          "value": "DENY"
        },
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        },
        {
          "key": "Referrer-Policy",
          "value": "strict-origin-when-cross-origin"
        }
      ]
    }
  ]
}

mediumProduction source map is public× 5

What it means
Source maps let anyone rebuild your original code from the live site, with your file names and comments. This test app had productionBrowserSourceMaps switched on, so the scanner found a map behind most of its script files. The real report gives each file its own card, which is why there are several.
Why it matters
Your code isn’t a secret in the way a key is, but readable source makes the next mistake much easier to find. The scanner also searched the maps for keys, then threw their contents away.
What we’d do
Switch production source maps off. If your error tracker needs them, upload them to it privately instead of serving them to everyone.
mediumProduction source map is public

A source map linked from the bundle can be downloaded. That exposes original file names and often the original source.

What a stranger can see

Fetched http://127.0.0.1:8797/_next/static/chunks/3d98331a-fee736153a203a87.js.map. Source contents were scanned for secrets and were not stored.

Fix prompt

A production source map is publicly downloadable, which hands a stranger your original source.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Disable production source maps, or block *.map files from public hosting."
The other 4 cards for this one, exactly as the report lists them
mediumProduction source map is public

A source map linked from the bundle can be downloaded. That exposes original file names and often the original source.

What a stranger can see

Fetched http://127.0.0.1:8797/_next/static/chunks/992-f0cbffdbe62967e3.js.map. Source contents were scanned for secrets and were not stored.

Fix prompt

A production source map is publicly downloadable, which hands a stranger your original source.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Disable production source maps, or block *.map files from public hosting."
mediumProduction source map is public

A source map linked from the bundle can be downloaded. That exposes original file names and often the original source.

What a stranger can see

Fetched http://127.0.0.1:8797/_next/static/chunks/main-app-f9d5a2906bf2ab07.js.map. Source contents were scanned for secrets and were not stored.

Fix prompt

A production source map is publicly downloadable, which hands a stranger your original source.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Disable production source maps, or block *.map files from public hosting."
mediumProduction source map is public

A source map linked from the bundle can be downloaded. That exposes original file names and often the original source.

What a stranger can see

Fetched http://127.0.0.1:8797/_next/static/chunks/app/page-98db774a11f3f8e4.js.map. Source contents were scanned for secrets and were not stored.

Fix prompt

A production source map is publicly downloadable, which hands a stranger your original source.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Disable production source maps, or block *.map files from public hosting."
mediumProduction source map is public

A source map linked from the bundle can be downloaded. That exposes original file names and often the original source.

What a stranger can see

Fetched http://127.0.0.1:8797/_next/static/chunks/webpack-753970e43f8b0b81.js.map. Source contents were scanned for secrets and were not stored.

Fix prompt

A production source map is publicly downloadable, which hands a stranger your original source.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Disable production source maps, or block *.map files from public hosting."

lowNo X-Content-Type-Options

What it means
The response didn’t send X-Content-Type-Options: nosniff.
Why it matters
Without it, a browser may guess that a file is a script and run it. It’s a small one.
What we’d do
Add the header. It takes a minute, and it’s in the same block as the others.
lowNo X-Content-Type-Options

Browsers may sniff a response as a script. nosniff stops that.

What a stranger can see

X-Content-Type-Options was not nosniff.

Fix prompt

A recommended browser security header is missing.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Add the security headers in this vercel.json snippet."

Add this to vercel.json (or the matching header settings on Netlify, Firebase Hosting, or your reverse proxy):
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; frame-ancestors 'none'"
        },
        {
          "key": "Strict-Transport-Security",
          "value": "max-age=63072000; includeSubDomains; preload"
        },
        {
          "key": "X-Frame-Options",
          "value": "DENY"
        },
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        },
        {
          "key": "Referrer-Policy",
          "value": "strict-origin-when-cross-origin"
        }
      ]
    }
  ]
}

lowNo Referrer-Policy

What it means
The response didn’t send a Referrer-Policy.
Why it matters
When a user clicks a link to another site, the browser can send along the full address they came from, including anything in the query string, like a reset token.
What we’d do
strict-origin-when-cross-origin is a sensible default. Same header block again.
lowNo Referrer-Policy

The browser may send the full URL, including tokens in the query string, to other sites.

What a stranger can see

The homepage response did not include a Referrer-Policy header.

Fix prompt

A recommended browser security header is missing.

Paste this into your builder (Lovable, Bolt, v0, Replit, or Cursor):

"Add the security headers in this vercel.json snippet."

Add this to vercel.json (or the matching header settings on Netlify, Firebase Hosting, or your reverse proxy):
{
  "headers": [
    {
      "source": "/(.*)",
      "headers": [
        {
          "key": "Content-Security-Policy",
          "value": "default-src 'self'; frame-ancestors 'none'"
        },
        {
          "key": "Strict-Transport-Security",
          "value": "max-age=63072000; includeSubDomains; preload"
        },
        {
          "key": "X-Frame-Options",
          "value": "DENY"
        },
        {
          "key": "X-Content-Type-Options",
          "value": "nosniff"
        },
        {
          "key": "Referrer-Policy",
          "value": "strict-origin-when-cross-origin"
        }
      ]
    }
  ]
}

Checks that stayed quiet

Not every line is bad news. The report also lists what we looked at that came back fine, so you know it was checked and not skipped.

  • Local target is not using HTTPS

    This loopback URL is HTTP, which is normal for a local fixture. A public launch still has to use HTTPS.

  • Next.js 15.5.28 is not in the advisories LockShip checks

    LockShip compared the live version with GHSA-2464-fhr4-w9xw, GHSA-9qr9-h5gf-34mp, GHSA-f82v-jwr5-mffw. New advisories keep coming, so keep an eye on the Next.js blog.

  • No hosted-builder signature

    No Lovable, Bolt, v0, Replit, or Cursor signature was found. Fix prompts use generic builder wording.

  • X-Powered-By says Next.js

    Every response tells visitors the site runs on Next.js. There is no version in it, and your scripts give the framework away anyway, so this is a small hint, not a problem. Next.js turns it off with poweredByHeader: false in next.config.

One of those only shows up because the test app ran on our own machine. On a real public site, plain HTTP is a high finding, and the scan also checks your TLS certificate and HSTS, which a local app can’t show.

What the scan did not test

Every scan is read-only. It never signs in and never sends anything but GET and HEAD, so some things are off limits by design. When the scan sees one of them, for example tables it could only test by writing to them, or Server Actions it would have to call for real, the report lists it under “What this scan did not test” and links to a way to check it yourself. This test app didn’t trigger any, so the list is empty here.

If your app uses Supabase

The notes app above doesn’t use a database we can probe, so it can’t show the finding LockShip was built around: a Supabase table that strangers can read with the public key. The key itself is meant to be public. The problem is a table without Row Level Security. This is the story behind CVE-2025-48757, and it’s where the report earns its money, because it hands you both the prompt for your builder and the SQL.

criticalAnonymous read on payments

A stranger with the public anon key can read payments. Columns in the proof row: id, email, stripe_customer_id, amount. LockShip stored one redacted row and did not write anything.

What a stranger can see

GET /rest/v1/payments?select=*&limit=1 returned at least 1 row. Values redacted.
id: [redacted string, 5 chars]
email: [email]
stripe_customer_id: [redacted string, 7 chars]
amount: [number]

Fix prompt

A stranger with your public anon key can read `payments`. Columns visible without logging in: id, email, stripe_customer_id, amount.

Paste this into Lovable's chat:

"Turn on row level security for the payments table. Authenticated users may select only their own rows. Remove any policy that allows anon or public select. Never put the service_role key in client code."

Then run the SQL below in the Supabase SQL editor and scan again.

RLS SQL

-- Review before running. Change user_id if that is not the owner column.
alter table public.payments enable row level security;
drop policy if exists "public read" on public.payments;
drop policy if exists "enable read for all users" on public.payments;
create policy "payments_select_own"
on public.payments
for select
to authenticated
using (auth.uid() = user_id);

-- Add insert, update, and delete policies the same way if the app writes this table.
-- Do not put the service_role key in client code. It bypasses these policies.

Read the SQL before you run it. It assumes the column that says who owns a row is called user_id, and it says so in its first line. If yours is called something else, change it. Our Supabase RLS checklist walks through the rest by hand.

3. Fix, re-scan, badge

Next we did what the report said, on a copy of the same test app at the same address:

  • Took the OpenAI key out of the browser. The call now goes through a server route that checks the login and reads the key from a server-only variable.
  • Made /api/notes require a session and return only your own notes.
  • Switched production source maps off.
  • Added the Content-Security-Policy, X-Frame-Options, nosniff and Referrer-Policy headers in next.config, and turned off X-Powered-By while we were in there.

Then we pressed re-scan. This is what came back:

Custom app

100

out of 100 · no critical or high findings

  • critical0
  • high0
  • medium0
  • low0

Re-scans are included for 7 days after you buy the certificate. The badge is only ever issued by a re-scan after payment, never by the first scan, so it always reflects a check you ran after fixing things. This one passed, so the certificate below went live.

4. The certificate

Launch Safe certificate · example

127.0.0.1

Status: Verified

Issued
18 Oct 2026
Last checked
18 Oct 2026
Score
100 / 100
Monitor
Not monitored
Launch Safe Verified

This is a point-in-time, best-effort, read-only review. It is not a penetration test and it is not a guarantee the app is safe to launch. Disclaimer.

How someone checks it

  • Every certificate gets its own public page on lockship.dev, at an address nobody can guess. The badge on your site links to it.
  • The badge isn’t a picture you download and keep. Your site loads it from LockShip each time, drawn from the current state of your scan, and it prints the hostname it was issued for. A visitor can click it and see that it belongs to the site they’re on.
  • With Monitor ($9/month), we re-scan about once a week. If a later scan finds a critical or high issue, the badge turns grey and says “Check failed” until a re-scan passes again. If Monitor ends, it switches to “Expired” once the paid month runs out.
  • Without Monitor, the badge stays as it was at your last passing re-scan, and the certificate page shows the date of that check and “Not monitored”. We’d rather show the date plainly than suggest we’re still watching when we aren’t.

You also get the whole report as a PDF to keep or forward. And the line on the certificate is meant seriously: this is a point-in-time, read-only check from the outside. It isn’t a penetration test and it can miss things. The disclaimer has the details.

Want to see yours?

The free scan gives you your score and the worst thing it finds, like section 1 above. You prove you own the domain first, so nobody can point it at a site that isn’t theirs. Pay $39 only if you want the rest.