F

HikmShield Security Report

Security Grade: F  ·  Score: 10/100

Project: /tmp/demo-saas-app

Scanned: July 7, 2026 at 01:20 AM EDT · 19 files in 28ms · v0.5.1

Executive Summary

F
Security Grade
34
Critical Issues
25
High Issues
12
Medium Issues
19
Files Scanned
3
Auto-Fixable

OWASP Top 10 (2021) Coverage

A01:2021A02:2021A03:2021A04:2021A05:2021A06:2021A07:2021A08:2021A09:2021A10:2021

Remediation Roadmap

Immediate — fix before next deploy (5 findings)
CRITICAL
Hardcoded Anthropic API key in source
.env.example:9
CRITICAL
Hardcoded Stripe webhook signing secret in source
.env.example:10
CRITICAL
Hardcoded Supabase service role key in source
.env.example:5
CRITICAL
Hardcoded JWT-like token in source
.env.example:5
CRITICAL
Server secret exposed via NEXT_PUBLIC_ prefix
.env.example:5
Short-term — fix within this sprint (5 findings)
HIGH
dangerouslySetInnerHTML without visible sanitization
app/ai-summary/page.tsx:5
HIGH
Auth-related route with no rate limiting
app/api/auth/login/route.ts
HIGH
eval() on dynamic input
app/api/llm/agent/route.ts:18
HIGH
LLM/expensive-API call with no rate limiting
app/api/llm/agent/route.ts:1
HIGH
LLM JSON output parsed without schema validation
app/api/llm/agent/route.ts:16
Planned — address in upcoming work (5 findings)
MEDIUM
LLM API call without max_tokens
app/api/llm/agent/route.ts:8
MEDIUM
console.log of secrets / env vars / session data
app/api/llm/route.ts:14
MEDIUM
Silently swallowed error (swallows error and returns [])
app/api/todos/route.ts:12
MEDIUM
File upload handler doesn't check MIME type
app/api/upload/route.ts:9
MEDIUM
File upload handler doesn't check size
app/api/upload/route.ts:9

Score Breakdown

Critical categories (auth, secrets, injection, rls, access-control, validation, path-traversal)
0/100
High categories (xss, csrf, deps, rate-limiting, webhooks, crypto, jwt, llm-injection, cookie-security)
0/100
Medium categories (cors, file-upload, exposure, silent-failures, headers)
30/100

Severity Distribution

Critical: 34High: 25Medium: 12

Findings

CRITICAL (34)

CRITICALHardcoded Anthropic API key in source
secrets.env.example:9
A02:2021CWE-798

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.

Evidence:
sk-ant…ghij
Fix: Move the Anthropic API key to process.env and ensure the env file is gitignored. Rotate the secret.
Before:
sk-ant-fakefixturekey1234567890abcdefghij
After:
process.env.ANTHROPIC_API_KEY
CRITICALHardcoded Stripe webhook signing secret in source
secrets.env.example:10
A02:2021CWE-798

Stripe 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.

Evidence:
whsec_…C123
Fix: Move the Stripe webhook signing secret to process.env and ensure the env file is gitignored. Rotate the secret.
Before:
whsec_fakesecretforfixturetestingABC123
After:
process.env.STRIPE_WEBHOOK_SIGNING_SECRET
CRITICALHardcoded Supabase service role key in source
secrets.env.example:5
A02:2021CWE-798

Supabase 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.

Evidence:
SUPABA…ture
Fix: Move the Supabase service role key to process.env and ensure the env file is gitignored. Rotate the secret.
Before:
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake.signature
After:
process.env.SUPABASE_SERVICE_ROLE_KEY
CRITICALHardcoded JWT-like token in source
secrets.env.example:5
A02:2021CWE-798

JWT-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.

Evidence:
eyJhbG…ture
Fix: Move the JWT-like token to process.env and ensure the env file is gitignored. Rotate the secret.
Before:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake.signature
After:
process.env.JWT-LIKE_TOKEN
CRITICALServer secret exposed via NEXT_PUBLIC_ prefix
secrets.env.example:5
A02:2021CWE-538

NEXT_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.

Evidence:
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY
Fix: Rename NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY to SUPABASE_SERVICE_ROLE_KEY and only reference it from server code.
Before:
NEXT_PUBLIC_SUPABASE_SERVICE_ROLE_KEY
After:
SUPABASE_SERVICE_ROLE_KEY
CRITICALServer secret exposed via NEXT_PUBLIC_ prefix
secrets.env.example:6
A02:2021CWE-538

NEXT_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.

Evidence:
NEXT_PUBLIC_STRIPE_SECRET_KEY
Fix: Rename NEXT_PUBLIC_STRIPE_SECRET_KEY to STRIPE_SECRET_KEY and only reference it from server code.
Before:
NEXT_PUBLIC_STRIPE_SECRET_KEY
After:
STRIPE_SECRET_KEY
CRITICALLLM output rendered as raw HTML
ai-securityapp/ai-summary/page.tsx:5
A03:2021CWE-79

dangerouslySetInnerHTML 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.

Evidence:
dangerouslySetInnerHTML={{ __html: aiResponse
Fix: Wrap the AI output with DOMPurify.sanitize() before passing it to dangerouslySetInnerHTML, or render via a Markdown library (react-markdown) that escapes raw HTML by default.
Before:
dangerouslySetInnerHTML={{ __html: message.content }}
After:
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(message.content) }}
CRITICALWrite-method API route has no auth check
authapp/api/auth/login/route.ts
A01:2021CWE-306

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.

Fix: At the top of each handler: const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) return new Response('Unauthorized', { status: 401 });
Before:
export async function POST(req) { ... db call ... }
After:
export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }
CRITICALRoute reads request body without schema validation
validationapp/api/auth/login/route.ts
A03:2021CWE-20

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.

Fix: Define a zod schema for the request body and parse it before using fields. const Body = z.object({...}); const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
Before:
const body = await req.json()
After:
const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
CRITICALWrite-method API route has no auth check
authapp/api/llm/agent/route.ts
A01:2021CWE-306

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.

Fix: At the top of each handler: const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) return new Response('Unauthorized', { status: 401 });
Before:
export async function POST(req) { ... db call ... }
After:
export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }
CRITICALLLM output passed to eval() or new Function()
ai-securityapp/api/llm/agent/route.ts:18
A03:2021CWE-94

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.

Evidence:
eval(generatedText
Fix: Use isolated-vm or restrict the model to calling whitelisted function names rather than generating arbitrary code.
Before:
eval(completion.choices[0].message.content)
After:
// Use isolated-vm: const isolate = new ivm.Isolate(); ...
CRITICALTool name or tool_choice derived from user input
ai-securityapp/api/llm/agent/route.ts:11
A03:2021CWE-94

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.

Evidence:
tools: body.t
Fix: Hardcode the tools array. If dynamic tool selection is required, maintain a server-side allowlist and map a safe identifier from the request to the tool definition.
Before:
tools: req.body.tools
After:
tools: ALLOWED_TOOLS  // server-side constant
CRITICALLLM output interpolated into database query
ai-securityapp/api/llm/agent/route.ts:20
A03:2021CWE-89

Interpolating 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.

Evidence:
sql = `select * from playbooks where name = '${generatedText
Fix: Switch to parameterised queries. Pass AI-derived values as bound parameters, never as part of the query string itself.
Before:
db.query(`SELECT * FROM ${response.content}`)
After:
db.query('SELECT * FROM items WHERE name = $1', [sanitizedValue])
CRITICALRoute reads request body without schema validation
validationapp/api/llm/route.ts
A03:2021CWE-20

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.

Fix: Define a zod schema for the request body and parse it before using fields. const Body = z.object({...}); const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
Before:
const body = await req.json()
After:
const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
CRITICALWrite-method API route has no auth check
authapp/api/proxy/route.ts
A01:2021CWE-306

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.

Fix: At the top of each handler: const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) return new Response('Unauthorized', { status: 401 });
Before:
export async function POST(req) { ... db call ... }
After:
export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }
CRITICALRoute reads request body without schema validation
validationapp/api/proxy/route.ts
A03:2021CWE-20

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.

Fix: Define a zod schema for the request body and parse it before using fields. const Body = z.object({...}); const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
Before:
const body = await req.json()
After:
const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
CRITICALSSRF — fetch() URL derived from request input
access-controlapp/api/proxy/route.ts:4
A10:2021CWE-918

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.

Evidence:
fetch(body.url);
Fix: Validate the URL against an allowlist before making the request: const allowedHosts = new Set(['api.partner.com']); const parsed = new URL(userUrl); if (!allowedHosts.has(parsed.hostname)) throw new Error('Disallowed host');
Before:
axios.get(body.url)
After:
if (!allowedHosts.has(new URL(body.url).hostname)) throw new Error('Disallowed host');
axios.get(body.url)
CRITICALWrite-method API route has no auth check
authapp/api/report/route.ts
A01:2021CWE-306

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.

Fix: At the top of each handler: const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) return new Response('Unauthorized', { status: 401 });
Before:
export async function POST(req) { ... db call ... }
After:
export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }
CRITICALRoute reads request body without schema validation
validationapp/api/report/route.ts
A03:2021CWE-20

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.

Fix: Define a zod schema for the request body and parse it before using fields. const Body = z.object({...}); const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
Before:
const body = await req.json()
After:
const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
CRITICALSQL query built with untrusted interpolation
injectionapp/api/report/route.ts:9
A03:2021CWE-89

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.

Evidence:
.query(`SELECT * FROM reports WHERE id = ${id}`);
Fix: Pass values as query parameters, never as interpolated/concatenated string fragments. e.g. db.query('SELECT * FROM users WHERE id = $1', [id]); or the parameterized tagged-template form sql`SELECT * FROM users WHERE id = ${id}` (which binds, not interpolates).
Before:
db.query(`SELECT ... ${value}`)
After:
db.query('SELECT ... WHERE x = $1', [value])
CRITICALSQL query built with untrusted interpolation
injectionapp/api/report/route.ts:12
A03:2021CWE-89

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.

Evidence:
.execute("DELETE FROM reports WHERE owner = '" + id + "'");
Fix: Pass values as query parameters, never as interpolated/concatenated string fragments. e.g. db.query('SELECT * FROM users WHERE id = $1', [id]); or the parameterized tagged-template form sql`SELECT * FROM users WHERE id = ${id}` (which binds, not interpolates).
Before:
db.query(`SELECT ... ${value}`)
After:
db.query('SELECT ... WHERE x = $1', [value])
CRITICALShell command built with untrusted interpolation
injectionapp/api/report/route.ts:15
A03:2021CWE-78

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.

Evidence:
exec(`ping -c1 ${host}`);
Fix: Replace exec(`cmd ${arg}`) with execFile('cmd', [arg]) (no shell interpretation), or spawn('cmd', [arg]). If a shell is unavoidable, validate the input against a strict allowlist first.
Before:
exec(`ping -c1 ${host}`)
After:
execFile('ping', ['-c1', host])
CRITICALWrite-method API route has no auth check
authapp/api/upload/route.ts
A01:2021CWE-306

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.

Fix: At the top of each handler: const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) return new Response('Unauthorized', { status: 401 });
Before:
export async function POST(req) { ... db call ... }
After:
export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }
CRITICALRoute reads request body without schema validation
validationapp/api/upload/route.ts
A03:2021CWE-20

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.

Fix: Define a zod schema for the request body and parse it before using fields. const Body = z.object({...}); const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
Before:
const body = await req.json()
After:
const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
CRITICALWrite-method API route has no auth check
authapp/api/users/route.ts
A01:2021CWE-306

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.

Fix: At the top of each handler: const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) return new Response('Unauthorized', { status: 401 });
Before:
export async function POST(req) { ... db call ... }
After:
export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }
CRITICALRoute reads request body without schema validation
validationapp/api/users/route.ts
A03:2021CWE-20

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.

Fix: Define a zod schema for the request body and parse it before using fields. const Body = z.object({...}); const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
Before:
const body = await req.json()
After:
const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
CRITICALuser_id sourced from request body
validationapp/api/users/route.ts:9
A01:2021CWE-639

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.

Evidence:
const { user_id, email, name } = await req.json
Fix: Drop user_id from the request schema. Inside the handler, derive it from getUser(): const { data: { user } } = await supabase.auth.getUser(); then use user.id.
Before:
const { user_id } = await req.json()
After:
const { data: { user } } = await supabase.auth.getUser();
CRITICALdelete/update by id without ownership check
access-controlapp/api/users/route.ts:21
A01:2021CWE-639

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.

Evidence:
.from("users").delete().eq("id"
Fix: Chain .eq('user_id', user.id) after the .eq('id', ...) so the row must belong to the caller.
Before:
.eq('id', id)
After:
.eq('id', id).eq('user_id', user.id)
CRITICALWrite-method API route has no auth check
authapp/api/webhook/stripe/route.ts
A01:2021CWE-306

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.

Fix: At the top of each handler: const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) return new Response('Unauthorized', { status: 401 });
Before:
export async function POST(req) { ... db call ... }
After:
export async function POST(req) { const { data: { user } } = await supabase.auth.getUser(); if (!user) return new Response('Unauthorized', { status: 401 }); ... }
CRITICALRoute reads request body without schema validation
validationapp/api/webhook/stripe/route.ts
A03:2021CWE-20

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.

Fix: Define a zod schema for the request body and parse it before using fields. const Body = z.object({...}); const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
Before:
const body = await req.json()
After:
const parsed = Body.safeParse(await req.json()); if (!parsed.success) return Response.json(parsed.error, { status: 400 });
CRITICALJWT uses algorithm "none" — signature skipped
jwtlib/auth-token.ts:4
A02:2021CWE-327

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.

Evidence:
jwt.sign(payload, process.env.JWT_SECRET!, { algorithm: "none" });
Fix: Pass algorithms:["HS256"] (or your actual algorithm) in the verify options, never "none".
Before:
jwt.verify(token, secret, { algorithms: ["none"] })
After:
jwt.verify(token, secret, { algorithms: ["HS256"] })
CRITICALservice_role key referenced in non-server code
rlslib/supabase-client.ts:5
A01:2021CWE-284

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.

Evidence:
SUPABASE_SERVICE_ROLE_KEY
Fix: Move the supabase client construction to a server-only file and use SUPABASE_ANON_KEY in browser code.
Before:
SUPABASE_SERVICE_ROLE_KEY
After:
// server-only — keep service_role out of client bundles
CRITICALSupabase table "posts" created without enabling RLS
rlssupabase/migrations/0001_init.sql:4
A01:2021CWE-284

Any 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.

Evidence:
create table posts
Fix: Add: alter table public.posts enable row level security; — and define explicit policies before this migration ships.
Before:
create table posts (...)
After:
create table posts (...);
alter table public.posts enable row level security;
CRITICALRLS enabled on "users" but no policies defined
rlssupabase/migrations/0001_init.sql:16
A01:2021CWE-284

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.

Evidence:
alter table users enable row level security
Fix: Add per-action policies: select policy with auth.uid() = user_id, plus separate insert/update/delete policies as needed.
Before:
alter table users enable row level security
After:
alter table users enable row level security;
create policy "users read own" on users for select using (auth.uid() = user_id);

HIGH (25)

HIGHdangerouslySetInnerHTML without visible sanitization
xssapp/ai-summary/page.tsx:5
A03:2021CWE-79

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.

Evidence:
dangerouslySetInnerHTML={{ __html: aiResponse }}
Fix: Wrap the html string with DOMPurify.sanitize(html) before passing it to dangerouslySetInnerHTML, or render via a markdown library that escapes by default.
Before:
dangerouslySetInnerHTML={{ __html: userHtml }}
After:
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userHtml) }}
HIGHAuth-related route with no rate limiting
rate-limitingapp/api/auth/login/route.ts
A04:2021CWE-307

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.

Fix: Wrap the handler with @upstash/ratelimit: const { success } = await ratelimit.limit(`auth:${ip}`); if (!success) return new Response('Too many requests', { status: 429 });
Before:
export async function POST(req) { /* sign in */ }
After:
export async function POST(req) { const { success } = await ratelimit.limit(`auth:${ip}`); if (!success) return new Response('Too many requests', { status: 429 }); /* sign in */ }
HIGHeval() on dynamic input
injectionapp/api/llm/agent/route.ts:18
A03:2021CWE-94

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).

Evidence:
eval(generatedText);
Fix: Remove eval(). For parsing data use JSON.parse; for dynamic dispatch use an object/Map keyed by the input; for math use a real parser. Never eval user input.
Before:
eval(expr)
After:
JSON.parse(expr) /* or a lookup map */
HIGHLLM/expensive-API call with no rate limiting
rate-limitingapp/api/llm/agent/route.ts:1
A04:2021CWE-770

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.

Evidence:
@anthropic-ai/sdk
Fix: Add @upstash/ratelimit per user/IP before the API call.
Before:
const response = await anthropic.messages.create(...)
After:
const { success } = await ratelimit.limit(`llm:${user.id}`); if (!success) return new Response('Rate limit', { status: 429 });
const response = await anthropic.messages.create(...)
HIGHLLM JSON output parsed without schema validation
ai-securityapp/api/llm/agent/route.ts:16
A03:2021CWE-20

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.

Evidence:
JSON.parse(generatedText)
Fix: Wrap the parsed result with a Zod schema. Use .safeParse() to handle invalid shapes without throwing.
Before:
const data = JSON.parse(completion.choices[0].message.content)
After:
const data = MySchema.parse(JSON.parse(completion.choices[0].message.content))
HIGHsupabase.auth.getSession() in server code
authapp/api/llm/route.ts:11
A07:2021CWE-287

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.

Evidence:
supabase.auth.getSession(
Fix: Replace getSession() with getUser() on server-side code. getUser() makes an authenticated request to /auth/v1/user and validates the JWT signature. auto-fixable
Before:
supabase.auth.getSession(
After:
supabase.auth.getUser(
HIGHLLM/expensive-API call with no rate limiting
rate-limitingapp/api/llm/route.ts:1
A04:2021CWE-770

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.

Evidence:
@anthropic-ai/sdk
Fix: Add @upstash/ratelimit per user/IP before the API call.
Before:
const response = await anthropic.messages.create(...)
After:
const { success } = await ratelimit.limit(`llm:${user.id}`); if (!success) return new Response('Rate limit', { status: 429 });
const response = await anthropic.messages.create(...)
HIGHUnsanitized user input passed directly to LLM messages
llm-injectionapp/api/llm/route.ts:21
A03:2021CWE-77

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.

Evidence:
{ role: "user", content: prompt }],
Fix: Sanitize user input before including it in messages: strip control characters, enforce length limits, and consider a separate sandboxed context for untrusted input. Never pass raw req.body strings into system prompts.
Before:
messages: [{ role: 'user', content: body.message }]
After:
messages: [{ role: 'user', content: sanitize(body.message, { maxLength: 2000 }) }]
HIGHeval() on dynamic input
injectionapp/api/report/route.ts:18
A03:2021CWE-94

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).

Evidence:
eval(expr);
Fix: Remove eval(). For parsing data use JSON.parse; for dynamic dispatch use an object/Map keyed by the input; for math use a real parser. Never eval user input.
Before:
eval(expr)
After:
JSON.parse(expr) /* or a lookup map */
HIGHGET handler performs state-changing operations
validationapp/api/todos/route.ts
A04:2021CWE-352

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.

Fix: Rename `export GET` to `export POST` (or PATCH/DELETE as appropriate) and update the client to use the matching method.
Before:
export async function GET(...)
After:
export async function POST(...)
HIGHCORS allows any origin AND credentials
corsapp/api/todos/route.ts:17
A05:2021CWE-942

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.

Evidence:
Access-Control-Allow-Origin: *
Fix: Replace the wildcard with an explicit allowlist of trusted origins (e.g. process.env.ALLOWED_ORIGINS), or drop Access-Control-Allow-Credentials.
Before:
Access-Control-Allow-Origin: *
After:
Access-Control-Allow-Origin: https://your-frontend.example
HIGHWebhook handler has no signature verification
webhooksapp/api/webhook/stripe/route.ts
A07:2021CWE-345

A 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.

Fix: For Stripe: const event = stripe.webhooks.constructEvent(body, sig, STRIPE_WEBHOOK_SECRET). For GitHub: @octokit/webhooks verify. For custom: crypto.timingSafeEqual against HMAC.
Before:
const body = await req.json(); // act on it
After:
const sig = req.headers.get('stripe-signature'); const event = stripe.webhooks.constructEvent(body, sig, secret); // act on event
HIGHdangerouslySetInnerHTML without visible sanitization
xssapp/page.tsx:6
A03:2021CWE-79

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.

Evidence:
dangerouslySetInnerHTML={{ __html: userHtml }}
Fix: Wrap the html string with DOMPurify.sanitize(html) before passing it to dangerouslySetInnerHTML, or render via a markdown library that escapes by default.
Before:
dangerouslySetInnerHTML={{ __html: userHtml }}
After:
dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(userHtml) }}
HIGHjwt.decode() used without jwt.verify()
jwtlib/auth-token.ts:8
A07:2021CWE-287

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.

Evidence:
jwt.decode(token) as { sub?: string; role?: string; iat?: nu
Fix: Replace jwt.decode() with jwt.verify(token, secret, { algorithms: ['HS256'] }) — it both validates and decodes.
Before:
const payload = jwt.decode(token)
After:
const payload = jwt.verify(token, secret, { algorithms: ['HS256'] })
HIGHHS256 used alongside an asymmetric key variable
jwtlib/auth-token.ts:24
A02:2021CWE-327

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.

Evidence:
algorithm: "HS256" });
Fix: Use RS256 with RSA keys, ES256 with EC keys. Never mix symmetric algorithms with asymmetric key material.
Before:
algorithm: "HS256"  // + PUBLIC_KEY
After:
algorithm: "RS256"  // + PUBLIC_KEY (verify) / PRIVATE_KEY (sign)
HIGHAuth token stored in localStorage
authlib/session.ts:4
A02:2021CWE-922

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).

Evidence:
localStorage.setItem("auth_token
Fix: Use @supabase/ssr's cookie-based session storage. Remove all client-side localStorage references for auth tokens.
Before:
localStorage.setItem('token', ...)
After:
// Tokens managed via httpOnly cookies by @supabase/ssr
HIGHdocument.cookie set without SameSite attribute
csrflib/session.ts:12
A01:2021CWE-352

Setting 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.

Evidence:
document.cookie = `session=${value}; httpOnly`
Fix: Append SameSite=Lax to the cookie string, or replace with cookies().set('name', value, { sameSite: 'lax', secure: true, httpOnly: true }).
Before:
document.cookie = `session=${value}; httpOnly`
After:
// cookies().set('session', value, { httpOnly: true, sameSite: 'lax', secure: true })
HIGHdocument.cookie assignment — HttpOnly not enforceable
cookie-securitylib/session.ts:12
A07:2021CWE-315

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.

Evidence:
document.cookie = `session=${value}; httpOnly`;
Fix: Move cookie creation to a server route that sets Set-Cookie with HttpOnly, Secure, and SameSite flags via the Response headers or Next.js cookies() API.
Before:
document.cookie = 'token=' + value
After:
// server-only: cookies().set('token', value, { httpOnly: true, secure: true })
HIGHProduction browser source maps enabled
secretsnext.config.js
A02:2021CWE-538

productionBrowserSourceMaps:true ships your TypeScript source (and any string literals like fallback secrets, internal endpoints, comments) to every browser. Disable in production.

Fix: Set productionBrowserSourceMaps to false, or gate it on process.env.NODE_ENV !== 'production'. auto-fixable
Before:
productionBrowserSourceMaps: true
After:
productionBrowserSourceMaps: false
HIGHNo lockfile committed
depspackage.json
A06:2021CWE-1035

Without 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.

Fix: Run `npm install` (or yarn/pnpm) and commit the generated lockfile.
Before:
(no lockfile)
After:
package-lock.json (committed)
HIGHnext@13.2.0 has known security advisories
depspackage.json
A06:2021CWE-1035

Next.js <13.4.0 has known SSRF and middleware bypass CVEs.

Evidence:
next: 13.2.0
Fix: Bump next to the latest non-vulnerable version. Run npm audit for the full picture.
Before:
"next": "13.2.0"
After:
"next": "latest-non-vulnerable-version"
HIGHjsonwebtoken@8.0.0 has known security advisories
depspackage.json
A06:2021CWE-1035

jsonwebtoken <8.5.1 has signature verification bypass (CVE-2022-23529).

Evidence:
jsonwebtoken: 8.0.0
Fix: Bump jsonwebtoken to the latest non-vulnerable version. Run npm audit for the full picture.
Before:
"jsonwebtoken": "8.0.0"
After:
"jsonwebtoken": "latest-non-vulnerable-version"
HIGHaxios@0.21.0 has known security advisories
depspackage.json
A06:2021CWE-1035

axios <1.6.0 has SSRF/redirect vulnerabilities.

Evidence:
axios: 0.21.0
Fix: Bump axios to the latest non-vulnerable version. Run npm audit for the full picture.
Before:
"axios": "0.21.0"
After:
"axios": "latest-non-vulnerable-version"
HIGHauth.jwt() used in RLS policy
rlssupabase/migrations/0001_init.sql:2
A07:2021CWE-345

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.

Evidence:
auth.jwt()
Fix: Use auth.uid() for the user ID. Only reach for auth.jwt() ->> 'custom_claim' when you genuinely need a custom claim.
Before:
auth.jwt()
After:
auth.uid()
HIGHauth.jwt() used in RLS policy
rlssupabase/migrations/0001_init.sql:27
A07:2021CWE-345

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.

Evidence:
auth.jwt()
Fix: Use auth.uid() for the user ID. Only reach for auth.jwt() ->> 'custom_claim' when you genuinely need a custom claim.
Before:
auth.jwt()
After:
auth.uid()

MEDIUM (12)

MEDIUMLLM API call without max_tokens
ai-securityapp/api/llm/agent/route.ts:8
A04:2021CWE-770

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.

Evidence:
messages.create({
Fix: Add max_tokens: <N> to the API call. For most responses 512–4096 is appropriate; reserve higher limits for deliberate long-form generation.
Before:
anthropic.messages.create({ model: 'claude-...', messages: [...] })
After:
anthropic.messages.create({ model: 'claude-...', max_tokens: 1024, messages: [...] })
MEDIUMconsole.log of secrets / env vars / session data
exposureapp/api/llm/route.ts:14
A09:2021CWE-532

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.

Evidence:
console.log("token:", token
Fix: Delete the log or replace with a structured logger that redacts secret fields (pino with serializers, etc).
Before:
console.log("token:", token
After:
// remove this log
MEDIUMSilently swallowed error (swallows error and returns [])
silent-failuresapp/api/todos/route.ts:12
A09:2021CWE-390

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.

Evidence:
.catch(() => [])
Fix: Log the error and decide explicitly whether to surface it or use a fallback.
Before:
.catch(() => [])
After:
.catch(err => { console.error(err); throw err; })
MEDIUMFile upload handler doesn't check MIME type
file-uploadapp/api/upload/route.ts:9
A04:2021CWE-434

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).

Evidence:
.formData()
Fix: Validate against an allowlist after extracting the file: const ALLOWED = ['image/png','image/jpeg']; if (!ALLOWED.includes(file.type)) return error;
Before:
const file = formData.get('file')
After:
const file = formData.get('file');
const ALLOWED = ['image/png','image/jpeg'];
if (!ALLOWED.includes(file.type)) return new Response('Invalid file type', { status: 400 });
MEDIUMFile upload handler doesn't check size
file-uploadapp/api/upload/route.ts:9
A04:2021CWE-770

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.

Evidence:
.formData()
Fix: Enforce a max size: if (file.size > 5 * 1024 * 1024) return new Response('File too large', { status: 413 });
Before:
const file = formData.get('file')
After:
const file = formData.get('file');
if (file.size > 5 * 1024 * 1024) return new Response('Too large', { status: 413 });
MEDIUMSilently swallowed error (empty catch block)
silent-failuresapp/api/upload/route.ts:16
A09:2021CWE-390

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.

Evidence:
catch (e) {}
Fix: Log the error and decide explicitly whether to surface it or use a fallback. auto-fixable
Before:
catch (e) {}
After:
catch (e) { console.error(e); }
MEDIUMMissing Strict-Transport-Security response header
headerspackage.json
A05:2021CWE-693

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.

Fix: In next.config.js add async headers() returning [{ source: '/(.*)', headers: [{ key: 'Strict-Transport-Security', value: '...' }] }], or set it in middleware.ts.
Before:
(no Strict-Transport-Security header)
After:
Strict-Transport-Security: <recommended value>
MEDIUMMissing X-Frame-Options response header
headerspackage.json
A05:2021CWE-693

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.

Fix: In next.config.js add async headers() returning [{ source: '/(.*)', headers: [{ key: 'X-Frame-Options', value: '...' }] }], or set it in middleware.ts.
Before:
(no X-Frame-Options header)
After:
X-Frame-Options: <recommended value>
MEDIUMMissing X-Content-Type-Options response header
headerspackage.json
A05:2021CWE-693

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.

Fix: In next.config.js add async headers() returning [{ source: '/(.*)', headers: [{ key: 'X-Content-Type-Options', value: '...' }] }], or set it in middleware.ts.
Before:
(no X-Content-Type-Options header)
After:
X-Content-Type-Options: <recommended value>
MEDIUMMissing Referrer-Policy response header
headerspackage.json
A05:2021CWE-693

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.

Fix: In next.config.js add async headers() returning [{ source: '/(.*)', headers: [{ key: 'Referrer-Policy', value: '...' }] }], or set it in middleware.ts.
Before:
(no Referrer-Policy header)
After:
Referrer-Policy: <recommended value>
MEDIUMMissing Permissions-Policy response header
headerspackage.json
A05:2021CWE-693

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.

Fix: In next.config.js add async headers() returning [{ source: '/(.*)', headers: [{ key: 'Permissions-Policy', value: '...' }] }], or set it in middleware.ts.
Before:
(no Permissions-Policy header)
After:
Permissions-Policy: <recommended value>
MEDIUMMissing Cross-Origin-Opener-Policy response header
headerspackage.json
A05:2021CWE-693

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.

Fix: In next.config.js add async headers() returning [{ source: '/(.*)', headers: [{ key: 'Cross-Origin-Opener-Policy', value: '...' }] }], or set it in middleware.ts.
Before:
(no Cross-Origin-Opener-Policy header)
After:
Cross-Origin-Opener-Policy: <recommended value>

Scan Metadata

Scanned at July 7, 2026 at 01:20 AM EDT
Duration 28ms
Files scanned 19
Scanner version v0.5.1
Stack detected
Next.jsSupabaseStripeTypeScript
Total findings 71

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).