Security Grade: F · Score: 10/100
OWASP Top 10 (2021) Coverage
Anthropic API key appears literally in a source file. Move to environment variable and add the file to .gitignore. Rotate the key — assume it is leaked.
sk-ant…ghijsk-ant-fakefixturekey1234567890abcdefghijprocess.env.ANTHROPIC_API_KEYStripe webhook signing secret appears literally in a source file. Move to environment variable and add the file to .gitignore. Rotate the key — assume it is leaked.
whsec_…C123whsec_fakesecretforfixturetestingABC123process.env.STRIPE_WEBHOOK_SIGNING_SECRETSupabase service role key appears literally in a source file. Move to environment variable and add the file to .gitignore. Rotate the key — assume it is leaked.
SUPABA…tureSUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake.signatureprocess.env.SUPABASE_SERVICE_ROLE_KEYJWT-like token appears literally in a source file. Move to environment variable and add the file to .gitignore. Rotate the key — assume it is leaked.
eyJhbG…tureeyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake.signatureprocess.env.JWT-LIKE_TOKENNEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY is server-side. Next.js inlines NEXT_PUBLIC_* vars into the client bundle, so this secret will ship to every browser. Drop the NEXT_PUBLIC_ prefix.
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEYNEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEYSUPABASE_SERVICE_ROLE_KEYNEXT_PUBLIC_STRIPE_SECRET_KEY is server-side. Next.js inlines NEXT_PUBLIC_* vars into the client bundle, so this secret will ship to every browser. Drop the NEXT_PUBLIC_ prefix.
NEXT_PUBLIC_STRIPE_SECRET_KEYNEXT_PUBLIC_STRIPE_SECRET_KEYSTRIPE_SECRET_KEYdangerouslySetInnerHTML is receiving a value that appears to come from an AI response. LLMs can be prompted to include <script> tags or event handlers — this is a prompt injection → XSS escalation path. Sanitize with DOMPurify before rendering, or use a Markdown renderer that escapes HTML.
dangerouslySetInnerHTML={{ __html: aiResponsedangerouslySetInnerHTML={{ __html: message.content }}dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(message.content) }}This route exports POST/PUT/PATCH/DELETE without any visible auth check (getUser, getSession, requireAuth, etc.). Anyone can hit it. Add a server-side auth check before the database call.
export async function POST(req) { ... db call ... }export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }This route parses req.json()/formData() but no schema library (zod, valibot, yup, joi) is in scope. Untyped body = trusts every client field. At minimum validate shape and types before passing to the database.
const body = await req.json()const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });This route exports POST/PUT/PATCH/DELETE without any visible auth check (getUser, getSession, requireAuth, etc.). Anyone can hit it. Add a server-side auth check before the database call.
export async function POST(req) { ... db call ... }export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }Running AI-generated code through eval() or new Function() is a direct code injection path: prompt injection → RCE. If code execution is intentional, use a sandboxed runtime (isolated-vm, Deno subprocess) with an explicit capability allowlist — never bare eval.
eval(generatedTexteval(completion.choices[0].message.content)// Use isolated-vm: const isolate = new ivm.Isolate(); ...Populating the tools array or tool_choice from user-controlled request data lets an attacker select which tool the model calls, including destructive or privileged ones. Tool definitions must be hardcoded server-side. Only tool argument values may be dynamic, and those must be schema-validated.
tools: body.ttools: req.body.toolstools: ALLOWED_TOOLS // server-side constantInterpolating AI-generated content directly into a SQL or database query string is a second-order injection risk. The model can be prompted to produce SQL fragments — e.g., `'; DROP TABLE users; --`. Use parameterised queries; never interpolate AI output into query strings.
sql = `select * from playbooks where name = '${generatedTextdb.query(`SELECT * FROM ${response.content}`)db.query('SELECT * FROM items WHERE name = $1', [sanitizedValue])This route parses req.json()/formData() but no schema library (zod, valibot, yup, joi) is in scope. Untyped body = trusts every client field. At minimum validate shape and types before passing to the database.
const body = await req.json()const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });This route exports POST/PUT/PATCH/DELETE without any visible auth check (getUser, getSession, requireAuth, etc.). Anyone can hit it. Add a server-side auth check before the database call.
export async function POST(req) { ... db call ... }export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }This route parses req.json()/formData() but no schema library (zod, valibot, yup, joi) is in scope. Untyped body = trusts every client field. At minimum validate shape and types before passing to the database.
const body = await req.json()const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });Server-side fetch() with a user-controlled URL is a Server-Side Request Forgery (SSRF) risk. Attackers point it at 169.254.169.254 (cloud metadata), internal services (localhost, 10.x.x.x), or other sensitive endpoints. Allowlist the allowed hosts/protocols before making the request.
fetch(body.url);axios.get(body.url)if (!allowedHosts.has(new URL(body.url).hostname)) throw new Error('Disallowed host');
axios.get(body.url)This route exports POST/PUT/PATCH/DELETE without any visible auth check (getUser, getSession, requireAuth, etc.). Anyone can hit it. Add a server-side auth check before the database call.
export async function POST(req) { ... db call ... }export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }This route parses req.json()/formData() but no schema library (zod, valibot, yup, joi) is in scope. Untyped body = trusts every client field. At minimum validate shape and types before passing to the database.
const body = await req.json()const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });A query is assembled via template literal, so any user-controlled value flows straight into SQL — classic injection. Use parameterized queries / bound placeholders instead of building the SQL string.
.query(`SELECT * FROM reports WHERE id = ${id}`);db.query(`SELECT ... ${value}`)db.query('SELECT ... WHERE x = $1', [value])A query is assembled via string concatenation, so any user-controlled value flows straight into SQL — classic injection. Use parameterized queries / bound placeholders instead of building the SQL string.
.execute("DELETE FROM reports WHERE owner = '" + id + "'");db.query(`SELECT ... ${value}`)db.query('SELECT ... WHERE x = $1', [value])child_process exec runs its argument through a shell; building it via template literal lets a user-controlled value inject arbitrary commands. Use execFile/spawn with an argument array (no shell), or strictly validate against an allowlist.
exec(`ping -c1 ${host}`);exec(`ping -c1 ${host}`)execFile('ping', ['-c1', host])This route exports POST/PUT/PATCH/DELETE without any visible auth check (getUser, getSession, requireAuth, etc.). Anyone can hit it. Add a server-side auth check before the database call.
export async function POST(req) { ... db call ... }export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }This route parses req.json()/formData() but no schema library (zod, valibot, yup, joi) is in scope. Untyped body = trusts every client field. At minimum validate shape and types before passing to the database.
const body = await req.json()const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });This route exports POST/PUT/PATCH/DELETE without any visible auth check (getUser, getSession, requireAuth, etc.). Anyone can hit it. Add a server-side auth check before the database call.
export async function POST(req) { ... db call ... }export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }This route parses req.json()/formData() but no schema library (zod, valibot, yup, joi) is in scope. Untyped body = trusts every client field. At minimum validate shape and types before passing to the database.
const body = await req.json()const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });Reading user_id from the request body lets the client set arbitrary user_id, including someone else's. Always derive user identity from the validated session (auth.uid() / supabase.auth.getUser()), never the body.
const { user_id, email, name } = await req.jsonconst { user_id } = await req.json()const { data: { user } } = await supabase.auth.getUser();Filtering by id alone lets an authenticated user mutate any row. Add `.eq('user_id', user.id)` (or whichever ownership column applies) to scope the mutation to records the caller owns. RLS should also enforce this, but defense in depth.
.from("users").delete().eq("id".eq('id', id).eq('id', id).eq('user_id', user.id)This route exports POST/PUT/PATCH/DELETE without any visible auth check (getUser, getSession, requireAuth, etc.). Anyone can hit it. Add a server-side auth check before the database call.
export async function POST(req) { ... db call ... }export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }This route parses req.json()/formData() but no schema library (zod, valibot, yup, joi) is in scope. Untyped body = trusts every client field. At minimum validate shape and types before passing to the database.
const body = await req.json()const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });algorithm:"none" disables JWT signature verification entirely. Any attacker can forge a token by crafting a valid header+payload and setting alg:none. Always specify HS256/RS256 or an explicit allowlist of algorithms.
jwt.sign(payload, process.env.JWT_SECRET!, { algorithm: "none" });jwt.verify(token, secret, { algorithms: ["none"] })jwt.verify(token, secret, { algorithms: ["HS256"] })service_role bypasses RLS entirely. Referencing it outside of /api/, server actions, or middleware risks bundling it into the client. Use the anon key in the browser; service_role only on the server.
SUPABASE_SERVICE_ROLE_KEYSUPABASE_SERVICE_ROLE_KEY// server-only — keep service_role out of client bundlesAny client with the anon key can SELECT/INSERT/UPDATE/DELETE rows in "posts" until RLS is enabled. Enable RLS in the same migration and add explicit policies.
create table postscreate table posts (...)create table posts (...);
alter table public.posts enable row level security;RLS without policies = nobody can read or write the table (default deny). If that's intentional, fine. Usually it isn't, and the team adds a "for all" policy later that opens everything. Define explicit per-action policies.
alter table users enable row level securityalter table users enable row level securityalter table users enable row level security;
create policy "users read own" on users for select using (auth.uid() = user_id);Rendering raw HTML with dangerouslySetInnerHTML is the most common XSS vector. Sanitize with DOMPurify or sanitize-html before rendering. If the content is markdown, use a renderer that escapes HTML by default.
dangerouslySetInnerHTML={{ __html: aiResponse }}dangerouslySetInnerHTML={{ __html: userHtml }}dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userHtml) }}Auth endpoints (login/signup/password reset) without rate limiting are credential-stuffing targets. Add @upstash/ratelimit or equivalent and limit per-IP and per-account.
export async function POST(req) { /* sign in */ }export async function POST(req) { const { success } = await ratelimit.limit(`auth:${ip}`); if (!success) return new Response('Too many requests', { status: 429 }); /* sign in */ }eval() executes its string argument as code. If any part is user-controlled, that's remote code execution. There is almost always a safer alternative (JSON.parse for data, a lookup map for dispatch).
eval(generatedText);eval(expr)JSON.parse(expr) /* or a lookup map */Routes that call Anthropic/OpenAI/Replicate without rate limiting are denial-of-wallet attacks waiting to happen. Anyone with the endpoint URL can rack up your API bill. Rate-limit per-IP and per-user.
@anthropic-ai/sdkconst response = await anthropic.messages.create(...)const { success } = await ratelimit.limit(`llm:${user.id}`); if (!success) return new Response('Rate limit', { status: 429 });
const response = await anthropic.messages.create(...)JSON.parse() on raw AI output without a schema validator (Zod, Joi, etc.) trusts the model to return the correct shape. Models hallucinate shapes, can be prompt-injected into returning unexpected keys, and structured-output guarantees are not absolute. Always validate the parsed object before using it.
JSON.parse(generatedText)const data = JSON.parse(completion.choices[0].message.content)const data = MySchema.parse(JSON.parse(completion.choices[0].message.content))getSession() reads the session from cookies/localStorage and returns it without revalidating against the Supabase server — meaning a forged cookie passes. Use getUser() on the server, which round-trips and validates the JWT.
supabase.auth.getSession(supabase.auth.getSession(supabase.auth.getUser(Routes that call Anthropic/OpenAI/Replicate without rate limiting are denial-of-wallet attacks waiting to happen. Anyone with the endpoint URL can rack up your API bill. Rate-limit per-IP and per-user.
@anthropic-ai/sdkconst response = await anthropic.messages.create(...)const { success } = await ratelimit.limit(`llm:${user.id}`); if (!success) return new Response('Rate limit', { status: 429 });
const response = await anthropic.messages.create(...)User-controlled content is inserted into LLM messages without sanitization. Attackers can craft prompts that override instructions, exfiltrate system context, or cause the model to take unintended actions (prompt injection). Validate, sanitize, and scope user input before passing it to any AI API.
{ role: "user", content: prompt }],messages: [{ role: 'user', content: body.message }]messages: [{ role: 'user', content: sanitize(body.message, { maxLength: 2000 }) }]eval() executes its string argument as code. If any part is user-controlled, that's remote code execution. There is almost always a safer alternative (JSON.parse for data, a lookup map for dispatch).
eval(expr);eval(expr)JSON.parse(expr) /* or a lookup map */Browsers, search engine bots, and CSRF attacks can trigger GET requests via images, prefetch, link tags. State changes belong in POST/PUT/PATCH/DELETE. Move the mutation to a POST handler and update the client.
export async function GET(...)export async function POST(...)Access-Control-Allow-Origin: * combined with Allow-Credentials: true lets any site read responses (including cookies) from your API. Pick: either explicit origin allowlist with credentials, or wildcard origin without credentials.
Access-Control-Allow-Origin: *Access-Control-Allow-Origin: *Access-Control-Allow-Origin: https://your-frontend.exampleA webhook route that doesn't verify the signature accepts forged events from anyone who knows the URL. Stripe, GitHub, Clerk etc. all send a signature header — verify it with the provider's helper or crypto.timingSafeEqual.
const body = await req.json(); // act on itconst sig = req.headers.get('stripe-signature'); const event = stripe.webhooks.constructEvent(body, sig, secret); // act on eventRendering raw HTML with dangerouslySetInnerHTML is the most common XSS vector. Sanitize with DOMPurify or sanitize-html before rendering. If the content is markdown, use a renderer that escapes HTML by default.
dangerouslySetInnerHTML={{ __html: userHtml }}dangerouslySetInnerHTML={{ __html: userHtml }}dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userHtml) }}jwt.decode() parses the token payload without checking the signature — it trusts whatever is in the token. Any attacker can craft a token with arbitrary claims. Always call jwt.verify() (which validates the signature) and use its output.
jwt.decode(token) as { sub?: string; role?: string; iat?: nuconst payload = jwt.decode(token)const payload = jwt.verify(token, secret, { algorithms: ['HS256'] })Signing with HS256 using an RSA/EC public key is the algorithm-confusion attack: an attacker can take your public key, sign a token with it as an HMAC secret (algorithm:HS256), and your server accepts it. If your keys are asymmetric (RSA/EC), use RS256/ES256.
algorithm: "HS256" });algorithm: "HS256" // + PUBLIC_KEYalgorithm: "RS256" // + PUBLIC_KEY (verify) / PRIVATE_KEY (sign)Tokens in localStorage are readable by any script on the page — XSS = total account takeover. Use httpOnly secure cookies (Supabase's @supabase/ssr does this by default).
localStorage.setItem("auth_tokenlocalStorage.setItem('token', ...)// Tokens managed via httpOnly cookies by @supabase/ssrSetting cookies via document.cookie without SameSite makes them eligible for cross-site requests, which is the CSRF threat model. Append `SameSite=Lax` (or Strict for auth cookies) to the cookie string, or move to cookies().set() with sameSite option.
document.cookie = `session=${value}; httpOnly`document.cookie = `session=${value}; httpOnly`// cookies().set('session', value, { httpOnly: true, sameSite: 'lax', secure: true })Cookies set via document.cookie from JavaScript can never have the HttpOnly flag (that flag prevents JS access, so it cannot be set from JS). Any session or auth token set this way is permanently exposed to XSS. Use server-side Set-Cookie headers with httpOnly:true instead.
document.cookie = `session=${value}; httpOnly`;document.cookie = 'token=' + value// server-only: cookies().set('token', value, { httpOnly: true, secure: true })productionBrowserSourceMaps:true ships your TypeScript source (and any string literals like fallback secrets, internal endpoints, comments) to every browser. Disable in production.
productionBrowserSourceMaps: trueproductionBrowserSourceMaps: falseWithout a lockfile (package-lock.json / yarn.lock / pnpm-lock.yaml / bun.lockb), every `npm install` may pull a different version of a transitive dep. This breaks builds and disables supply-chain pinning. Commit the lockfile.
(no lockfile)package-lock.json (committed)Next.js <13.4.0 has known SSRF and middleware bypass CVEs.
next: 13.2.0"next": "13.2.0""next": "latest-non-vulnerable-version"jsonwebtoken <8.5.1 has signature verification bypass (CVE-2022-23529).
jsonwebtoken: 8.0.0"jsonwebtoken": "8.0.0""jsonwebtoken": "latest-non-vulnerable-version"axios <1.6.0 has SSRF/redirect vulnerabilities.
axios: 0.21.0"axios": "0.21.0""axios": "latest-non-vulnerable-version"auth.jwt() returns the raw JWT object, which is easy to misuse and read fields that aren't authenticated. Prefer auth.uid() for identifying the user; use auth.jwt() only when you specifically need a custom claim.
auth.jwt()auth.jwt()auth.uid()auth.jwt() returns the raw JWT object, which is easy to misuse and read fields that aren't authenticated. Prefer auth.uid() for identifying the user; use auth.jwt() only when you specifically need a custom claim.
auth.jwt()auth.jwt()auth.uid()Calling the Anthropic or OpenAI API without max_tokens means a single request can consume the model's full context window (up to 200k tokens for Claude). This enables denial-of-wallet attacks and unbounded latency spikes. Set an explicit max_tokens appropriate to your use case.
messages.create({anthropic.messages.create({ model: 'claude-...', messages: [...] })anthropic.messages.create({ model: 'claude-...', max_tokens: 1024, messages: [...] })Logging secret-bearing variables sprays them into Vercel logs, browser devtools, error trackers. Strip these before merging — and assume anything printed has been read by someone.
console.log("token:", tokenconsole.log("token:", token// remove this logSilently swallowing an error hides bugs and security signals: failed auth checks, network errors, RLS rejections all become 'works fine to me'. At minimum log the error; better, propagate it.
.catch(() => []).catch(() => []).catch(err => { console.error(err); throw err; })Without a server-side MIME/extension check, attackers can upload .php/.html/.svg and bypass storage policies. Use file.type against an allowlist (e.g. image/png, image/jpeg, application/pdf).
.formData()const file = formData.get('file')const file = formData.get('file');
const ALLOWED = ['image/png','image/jpeg'];
if (!ALLOWED.includes(file.type)) return new Response('Invalid file type', { status: 400 });Without a size cap on uploads, attackers can fill your storage and run up your bill. Enforce a max size before reading the file body.
.formData()const file = formData.get('file')const file = formData.get('file');
if (file.size > 5 * 1024 * 1024) return new Response('Too large', { status: 413 });Silently swallowing an error hides bugs and security signals: failed auth checks, network errors, RLS rejections all become 'works fine to me'. At minimum log the error; better, propagate it.
catch (e) {}catch (e) {}catch (e) { console.error(e); }Strict-Transport-Security is not set anywhere in middleware.ts or next.config.* — every response goes out without it. Add it via Next.js headers() config or middleware response headers.
(no Strict-Transport-Security header)Strict-Transport-Security: <recommended value>X-Frame-Options is not set anywhere in middleware.ts or next.config.* — every response goes out without it. Add it via Next.js headers() config or middleware response headers.
(no X-Frame-Options header)X-Frame-Options: <recommended value>X-Content-Type-Options is not set anywhere in middleware.ts or next.config.* — every response goes out without it. Add it via Next.js headers() config or middleware response headers.
(no X-Content-Type-Options header)X-Content-Type-Options: <recommended value>Referrer-Policy is not set anywhere in middleware.ts or next.config.* — every response goes out without it. Add it via Next.js headers() config or middleware response headers.
(no Referrer-Policy header)Referrer-Policy: <recommended value>Permissions-Policy is not set anywhere in middleware.ts or next.config.* — every response goes out without it. Add it via Next.js headers() config or middleware response headers.
(no Permissions-Policy header)Permissions-Policy: <recommended value>Cross-Origin-Opener-Policy is not set anywhere in middleware.ts or next.config.* — every response goes out without it. Add it via Next.js headers() config or middleware response headers.
(no Cross-Origin-Opener-Policy header)Cross-Origin-Opener-Policy: <recommended value>HikmShield performs static analysis across 22 security categories. Static analysis has inherent limits — complement this report with dynamic testing, penetration testing, and dependency auditing (npm audit).