Secrets & leaked credentials
1 finding
2 checks ran
This page is sample data so the report UI can be reviewed without a live target. It is not the result of a scan in this environment. Real scans require ownership proof and consent.
Sample report
17 findings
Origin: Sample application
Score starts at 100. Critical −25, high −12, medium −6, low −2.
This score reflects a scan at one point in time. It is not a guarantee of security, and it is not a promise that the app will stay in this condition. Run a new scan after you ship changes.
1 finding
2 checks ran
3 findings
5 checks ran
4 findings
8 checks ran
2 findings
3 checks ran
3 findings
5 checks ran
2 findings
8 checks ran
1 finding
8 checks ran
1 finding
1 checks ran
Evidence. The database URL returned JSON without a user credential. Top-level keys: users.
Impact. The client config points at a database that returns data without a signed-in user. Anyone who knows the project URL can read it.
Location.
GET https://sample.example/firebase-rules-open
Proof.
Request: GET https://sample.example/firebase-rules-open
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Start from deny-all rules, then open only the paths a signed-in user should see.
{
"rules": {
".read": false,
".write": false,
"users": {
"$uid": {
".read": "auth != null && auth.uid === $uid",
".write": "auth != null && auth.uid === $uid"
}
}
}
}
Deploy rules before shipping the client config. Do not leave ".read": true or ".write": true on a parent path.Evidence. The public client key could read users (2 row sample, fields id, email). Write methods are advertised for this table.
Impact. Anyone who loads the app can read rows with the anon key that ships in the browser. Signup data, emails, and private records are exposed even when the UI hides them.
Location.
GET https://sample.example/supabase-rls-disabled
Proof.
Request: GET https://sample.example/supabase-rls-disabled
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Turn on row level security and add a policy before exposing the table to the anon role. alter table public.users enable row level security; create policy "read own row" on public.users for select to authenticated using (auth.uid() = id); Repeat for every table in the public schema. In the Supabase dashboard, confirm the anon role cannot select rows you would not paste into a public chat.
Evidence. Public JavaScript included a service-role credential and a Stripe secret key. The values were redacted.
Impact. A service-role key, secret API key, or private token is in JavaScript the browser downloads. Anyone can copy it and act as your server.
Location.
GET https://sample.example/assets/app.js
Line 12, column 7
Secret sk_t…0000
Proof.
Request: GET https://sample.example/assets/app.js
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Remove the secret from client code and rotate it now. The old value is public. 1. Revoke or roll the key in the provider dashboard. 2. Keep the new value in a server-only variable. Do not prefix it with NEXT_PUBLIC_ or VITE_. 3. Call the provider from a server route, and return only the data the page needs. Search the repo for the leaked prefix and for NEXT_PUBLIC_ / VITE_ variables that hold private keys.
Evidence. These paths returned privileged content with no session cookie: /admin, /api/admin/users.
Impact. An admin page or API returns privileged data to a request that sent no session. Hiding the link in the UI does not protect it.
Location.
GET https://sample.example/admin-surface-open
Proof.
Request: GET https://sample.example/admin-surface-open
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Check the session on the server before returning admin data.
// example server handler
const session = await getSession(request);
if (!session || session.role !== "admin") {
return new Response("Forbidden", { status: 403 });
}
Do this in the route that reads or writes data, not only in a client component. Return 401 or 403 when the cookie is missing.Evidence. GET /api/users/1 and GET /api/users/2 both returned different records with no session cookie.
Impact. Changing a number in the URL returns a different person's record. Attackers enumerate ids and download the collection.
Location.
GET https://sample.example/idor-sequential-ids
Proof.
Request: GET https://sample.example/idor-sequential-ids
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Authorize every object on the server. Do not trust the id in the URL.
const row = await db.order.findFirst({
where: { id: params.id, userId: session.userId },
});
if (!row) return new Response("Not found", { status: 404 });
Prefer unguessable ids as a backup, but the real fix is the ownership check. A read of someone else's id should look the same as a missing id.Evidence. The storage API listed 1 bucket: avatars.
Impact. The bucket listing is public, so files you thought were unlisted can be enumerated and downloaded.
Location.
GET https://sample.example/public-storage-bucket
Proof.
Request: GET https://sample.example/public-storage-bucket
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Turn off public listing and serve files through a short-lived signed URL. - Supabase: set the bucket to private, then create signed URLs on the server after you check the user. - Firebase Storage: use rules that require auth, for example allow read: if request.auth != null && request.auth.uid == userId; - S3-style buckets: disable public ACLs and block public access at the account level. A file can stay downloadable by a direct link you handed to its owner without letting the world list every object.
Evidence. Client code gates premium access with a localStorage flag named plan.
Impact. The paywall is a local flag. Changing local storage or a client variable unlocks paid features without a payment.
Location.
GET https://sample.example/client-side-paywall
Proof.
Request: GET https://sample.example/client-side-paywall
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Record the purchase on the server and check it there before sending paid data.
const entitled = await db.subscription.findFirst({
where: { userId: session.userId, status: "active" },
});
if (!entitled) return new Response("Payment required", { status: 402 });
The browser may hide a button, but the API that returns the premium payload has to do the check. Confirm the Stripe webhook updates that row only after a verified event.Evidence. An unsigned empty POST was accepted: /api/stripe/webhook returned 200.
Impact. Anyone can POST a fake payment event and mark themselves as paid if the handler trusts the body.
Location.
POST https://sample.example/stripe-webhook-unsigned
Proof.
Request: POST https://sample.example/stripe-webhook-unsigned
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Verify the Stripe-Signature header with the webhook secret before reading the event.
const body = await request.text();
const signature = request.headers.get("stripe-signature");
let event;
try {
event = stripe.webhooks.constructEvent(body, signature, process.env.STRIPE_WEBHOOK_SECRET);
} catch {
return new Response("Invalid signature", { status: 400 });
}
Reject missing or invalid signatures with 400. Do not grant access from the success redirect alone.Evidence. POST /api/chat returned 200 with no session cookie.
Impact. An open AI route spends your provider budget and can be driven by anyone who can reach the URL.
Location.
POST https://sample.example/llm-endpoint-open
Proof.
Request: POST https://sample.example/llm-endpoint-open
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Require a session and a rate limit before calling the model.
const session = await getSession(request);
if (!session) return new Response("Unauthorized", { status: 401 });
await rateLimit(`ai:${session.userId}`, { max: 20, windowSeconds: 3600 });
Keep the provider key on the server. Return a generic error when the limit is hit.Evidence. POST /api/chat included hidden instruction text in the response. The text was not copied into this report.
Impact. A short user message got the route to repeat system instructions or other hidden prompt text. Those instructions often include secrets, policies, or private business rules.
Location.
POST https://sample.example/llm-prompt-leak
Proof.
Request: POST https://sample.example/llm-prompt-leak
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Do not interpolate raw system instructions into a response, and do not trust the model to keep them. - Authenticate the route and rate-limit it. - Keep secrets out of the prompt. Pass them as server-side tool calls instead. - Add a server-side check that refuses to return the system prompt text. - Treat user text as data, not as a new instruction channel, and filter outputs before they reach the client.
Evidence. These paths returned environment files: /.env.
Impact. The site serves an env file. Even values that are not cloud keys often include database URLs and session secrets.
Location.
GET https://sample.example/exposed-dotenv
Proof.
Request: GET https://sample.example/exposed-dotenv
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Remove the file from the public directory and block the path at the host.
# example static host / edge rule
location ~ /\.env { deny all; }
Rebuild without copying .env into the web root. Rotate any secret that was in the file.Evidence. Public client code sends role: "admin" during signup.
Impact. The browser chooses a role such as admin. If the server stores that field, anyone can sign up as an administrator.
Location.
GET https://sample.example/signup-privileged-role
Proof.
Request: GET https://sample.example/signup-privileged-role
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Ignore any role sent by the client. Set roles on the server from an allow-list you control.
const { email, password } = await request.json();
await db.user.create({
data: { email, passwordHash, role: "member" },
});
Promote someone to admin in a separate server-only action that checks the caller is already an admin.Evidence. /auth/callback redirected to example.com based on the next parameter.
Impact. A callback parameter sends the browser to another site. After login, people can be walked onto a lookalike page that steals the session they just created.
Location.
GET https://sample.example/open-redirect
Proof.
Request: GET https://sample.example/open-redirect
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Allow only relative paths or an explicit list of hosts.
function safeNext(value: string | null) {
if (!value || !value.startsWith("/") || value.startsWith("//")) return "/dashboard";
return value;
}
return redirect(safeNext(nextParam));
Do not pass a full URL from the query string into the Location header.Evidence. A cross-origin request was answered with Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true.
Impact. A browser on another site can send credentialed requests and read the response. That turns a logged-in user's cookies into a cross-site data leak.
Location.
GET https://sample.example/cors-credential-wildcard
Proof.
Request: GET https://sample.example/cors-credential-wildcard
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Reflect only origins you operate, and avoid combining a wildcard with credentials.
const allowed = new Set(["https://app.example.com"]);
const origin = request.headers.get("origin");
if (origin && allowed.has(origin)) {
headers.set("Access-Control-Allow-Origin", origin);
headers.set("Vary", "Origin");
}
Do not send Access-Control-Allow-Credentials: true together with Access-Control-Allow-Origin: *.Evidence. The homepage response did not include a Content-Security-Policy header.
Impact. Without a content security policy, a single injected script can talk to any host and read the page.
Location.
GET https://sample.example/missing-csp
Proof.
Request: GET https://sample.example/missing-csp
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Send a policy that limits scripts to your own origin. Start strict and loosen only where a host is required. Content-Security-Policy: default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none' Add the hosts your app actually calls, such as your API and image CDN. Avoid unsafe-inline and unsafe-eval on script-src.
Headline findings for this family are in the priority list above.
Headline findings for this family are in the priority list above.
Headline findings for this family are in the priority list above.
Evidence. The q query parameter was copied into the HTML response without encoding a < character.
Impact. A crafted link can run script in a visitor's session because the server copies the query string into HTML as markup.
Location.
GET https://sample.example/reflected-xss
Proof.
Request: GET https://sample.example/reflected-xss
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Encode untrusted text when you insert it into HTML, or render it as text.
function escapeHtml(value) {
return value
.replaceAll("&", "&")
.replaceAll("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """);
}
In React, avoid dangerouslySetInnerHTML for request data. Pair this with a Content-Security-Policy that does not allow unsafe inline scripts.Headline findings for this family are in the priority list above.
Headline findings for this family are in the priority list above.
Headline findings for this family are in the priority list above.
Evidence. The public manifest lists lodash@4.17.15 (CVE-2021-23337: Prototype pollution in lodash before 4.17.21).
Impact. The deployed app advertises a library version with a published vulnerability, and the manifest itself is public.
Location.
GET https://sample.example/vulnerable-dependency
Proof.
Request: GET https://sample.example/vulnerable-dependency
Response.
HTTP 200
content-type: application/json
{"sample":true}Steps to reproduce.
Fix.
Upgrade the package past the affected version and redeploy. Then stop publishing the manifest. npm install lodash@4.17.21 Use the fixed version named in the finding. Remove package.json and the lockfile from the public host so version details stay in your repo.
A manual pentest is a better fit when you need those covered in depth.