
Vibe Security
- 2.5k installs
- 912 repo stars
- Updated March 15, 2026
- raroque/vibe-security-skill
vibe-security audits AI-generated apps for common secrets, auth, RLS, payment, and deployment vulnerabilities.
About
The vibe-security skill audits codebases for security vulnerabilities commonly introduced when AI assistants generate applications quickly without security fundamentals. The core principle is never trust the client: prices, user IDs, roles, subscription status, feature flags, and rate limits must be validated server-side. The audit process walks nine areas loading reference files only when relevant: secrets and environment variables, database access control including Supabase RLS and Firebase rules, authentication and authorization, rate limiting, payment security, mobile security, AI and LLM integration risks, deployment configuration, and data access with input validation. Findings are organized by severity from Critical to Low with file references, vulnerability names, attacker impact, and before-after fixes. Critical issues like exposed service_role keys or disabled RLS are flagged immediately at the top. When generating new code, relevant reference files are consulted proactively to prevent introducing vulnerabilities before they ship.
- Never trust the client for prices, roles, or rate limit counters.
- Nine-step audit loads only references for technologies in the codebase.
- Supabase RLS and Firebase rules are the top critical risk area.
- Critical findings surface first with concrete attacker impact.
- Reference files guide proactive secure code generation, not just review.
Vibe Security by the numbers
- 2,472 all-time installs (skills.sh)
- +78 installs in the week ending Aug 5, 2026 (Skillselion tracking)
- Ranked #204 of 2,203 Security skills by installs in the Skillselion catalog
- Security screen: HIGH risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
vibe-security capabilities & compatibility
- Capabilities
- nine area systematic security audit with optiona · severity ranked findings with exploit impact and · secrets scan for client exposed env prefixes · database rls and firebase rules verification · proactive secure patterns when generating auth o
- Use cases
- security audit · code review
What vibe-security says it does
Never trust the client.
This is the #1 source of critical vulnerabilities in vibe-coded apps.
Report only genuine security issues.
npx skills add https://github.com/raroque/vibe-security-skill --skill vibe-securityAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 2.5k |
|---|---|
| repo stars | ★ 912 |
| Security audit | 2 / 3 scanners passed |
| Last updated | March 15, 2026 |
| Repository | raroque/vibe-security-skill ↗ |
Is this vibe-coded app safe from exposed keys, broken RLS, or client-side trust bugs?
Audit vibe-coded apps for exposed secrets, broken access control, auth gaps, payment flaws, and other AI-introduced vulnerabilities.
Who is it for?
Apps built rapidly with AI touching auth, payments, databases, API keys, or user data.
Skip if: Skip for style-only reviews or codebases with no auth, payments, or data access patterns.
When should I use this skill?
User asks about security, vibe coding safety, audit, or whether someone can hack this.
What you get
Severity-ranked findings with file locations, impact, and before-after fixes.
- Security audit findings
- Anti-pattern remediation guidance
Files
Audit code for security vulnerabilities commonly introduced by AI code generation. These issues are prevalent in "vibe-coded" apps — projects built rapidly with AI assistance where security fundamentals get skipped.
AI assistants consistently get these patterns wrong, leading to real breaches, stolen API keys, and drained billing accounts. This skill exists to catch those mistakes before they ship.
The Core Principle
Never trust the client. Every price, user ID, role, subscription status, feature flag, and rate limit counter must be validated or enforced server-side. If it exists only in the browser, mobile bundle, or request body, an attacker controls it.
Audit Process
Examine the codebase systematically. For each step, load the relevant reference file only if the codebase uses that technology or pattern. Skip steps that aren't relevant.
1. Secrets & Environment Variables — Scan for hardcoded API keys, tokens, or credentials. Check for secrets exposed via client-side env var prefixes (NEXT_PUBLIC_, VITE_, EXPO_PUBLIC_). Verify .env is in .gitignore. See references/secrets-and-env.md.
2. Database Access Control — Check Supabase RLS policies, Firebase Security Rules, or Convex auth guards. This is the #1 source of critical vulnerabilities in vibe-coded apps. See references/database-security.md.
3. Authentication & Authorization — Validate JWT handling, middleware auth, Server Action protection, and session management. See references/authentication.md.
4. Rate Limiting & Abuse Prevention — Ensure auth endpoints, AI calls, and expensive operations have rate limits. Verify rate limit counters can't be tampered with. See references/rate-limiting.md.
5. Payment Security — Check for client-side price manipulation, webhook signature verification, and subscription status validation. See references/payments.md.
6. Mobile Security — Verify secure token storage, API key protection via backend proxy, and deep link validation. See references/mobile.md.
7. AI / LLM Integration — Check for exposed AI API keys, missing usage caps, prompt injection vectors, and unsafe output rendering. See references/ai-integration.md.
8. Deployment Configuration — Verify production settings, security headers, source map exposure, and environment separation. See references/deployment.md.
9. Data Access & Input Validation — Check for SQL injection, ORM misuse, and missing input validation. See references/data-access.md.
If doing a partial review or generating code in a specific area, load only the relevant reference files.
Core Instructions
- Report only genuine security issues. Do not nitpick style or non-security concerns.
- When multiple issues exist, prioritize by exploitability and real-world impact.
- If the codebase doesn't use a particular technology (e.g., no Supabase), skip that section entirely.
- When generating new code, consult the relevant reference files proactively to avoid introducing vulnerabilities in the first place.
- If you find a critical issue (exposed secrets, disabled RLS, auth bypass), flag it immediately at the top of your response — don't bury it in a long list.
Output Format
Organize findings by severity: Critical → High → Medium → Low.
For each issue: 1. State the file and relevant line(s). 2. Name the vulnerability. 3. Explain what an attacker could do (concrete impact, not abstract risk). 4. Show a before/after code fix.
Skip areas with no issues. End with a prioritized summary.
Example Output
Critical
`lib/supabase.ts:3` — Supabase `service_role` key exposed in client bundle
The service_role key bypasses all Row-Level Security. Anyone can extract it from the browser bundle and read, modify, or delete every row in your database.
// Before
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_SERVICE_KEY!)
// After — use the anon key client-side; service_role belongs only in server-side code
const supabase = createClient(url, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!)High
`app/api/checkout/route.ts:15` — Price taken from client request body
An attacker can set any price (including $0.01) by modifying the request. Prices must be looked up server-side.
// Before
const session = await stripe.checkout.sessions.create({
line_items: [{ price_data: { unit_amount: req.body.price } }]
})
// After — look up the price server-side
const product = await db.products.findUnique({ where: { id: req.body.productId } })
const session = await stripe.checkout.sessions.create({
line_items: [{ price: product.stripePriceId }]
})Summary
1. Service role key exposed (Critical): Anyone can bypass all database security. Rotate the key immediately and move it to server-side only. 2. Client-controlled pricing (High): Attackers can purchase at any price. Use server-side price lookup.
When Generating Code
These rules also apply proactively. Before writing code that touches auth, payments, database access, API keys, or user data, consult the relevant reference file to avoid introducing the vulnerability in the first place. Prevention is better than detection.
References
references/secrets-and-env.md— API keys, tokens, environment variable configuration, and.gitignorerules.references/database-security.md— Supabase RLS, Firebase Security Rules, and Convex auth patterns.references/authentication.md— JWT verification, middleware, Server Actions, and session management.references/rate-limiting.md— Rate limiting strategies and abuse prevention.references/payments.md— Stripe security, webhook verification, and price validation.references/mobile.md— React Native and Expo security: secure storage, API proxy, deep links.references/ai-integration.md— LLM API key protection, usage caps, prompt injection, and output sanitization.references/deployment.md— Production configuration, security headers, and environment separation.references/data-access.md— SQL injection prevention, ORM safety, and input validation.
interface:
display_name: "Vibe Security"
short_description: "Audits vibe-coded apps for common security vulnerabilities."
brand_color: "#DC2626"
default_prompt: "Use $vibe-security to audit my project for security issues."
policy:
allow_implicit_invocation: true
AI / LLM Integration Security
API Keys Are Server-Side Only
AI API keys (OpenAI, Anthropic, Google, etc.) must never appear in client-side code. They allow unlimited API usage at your expense. A leaked key can drain thousands of dollars in minutes.
- No
NEXT_PUBLIC_OPENAI_API_KEY - No API keys in React Native / Expo bundles
- No API keys in client-side JavaScript
All AI API calls go through your backend. The client sends the user's message to your server; your server calls the AI API.
Spending Caps
Set hard spending caps on every AI API provider:
- OpenAI: Usage limits in dashboard
- Anthropic: Spending limits in console
- Google: Budget alerts in Cloud Console
Also implement per-user usage limits in your application:
- Track token usage per user in your database
- Set daily/monthly caps per user or per tier
- Return a clear error when limits are exceeded
- Don't rely on the AI provider's caps alone — they may have lag
Prompt Injection
User input must be sanitized before inclusion in prompts. Never concatenate raw user input into system prompts:
// BAD: user can override system instructions
const prompt = `You are a helpful assistant. User says: ${userInput}`;
// BETTER: separate system and user messages
const messages = [
{ role: 'system', content: 'You are a helpful assistant.' },
{ role: 'user', content: userInput },
];Even with separate messages, be aware that sophisticated prompt injection can still occur. For high-stakes applications, consider:
- Input validation and filtering
- Output validation before acting on LLM responses
- Limiting the LLM's capabilities (no tool access for user-facing chat)
LLM Output Is Untrusted
LLM responses should be treated as untrusted user input:
- Sanitize before rendering as HTML — LLM output can contain script tags or event handlers
- Never execute LLM output as code without sandboxing
- Validate tool/function call parameters — if using function calling, validate all returned parameters against an allowlist and schema before executing
Tool / Function Calling
If your application gives an LLM access to tools (database queries, API calls, file operations):
- Restrict operations to a safe allowlist
- Validate all parameters from the LLM against a schema
- Use least-privilege access (read-only where possible)
- Log all tool invocations for audit
- Never let the LLM construct raw SQL or shell commands from user input
Authentication & Authorization
JWT Handling
- Use `jwt.verify()`, never `jwt.decode()` alone.
decodereads the payload without checking the signature — an attacker can forge any payload. - Explicitly reject `"alg": "none"`. Some JWT libraries accept unsigned tokens if the algorithm is set to
"none". Your verification must reject this. - Validate issuer, audience, and expiration — not just the signature.
// BAD: reads token without verifying signature
const payload = jwt.decode(token);
// GOOD: verifies signature, rejects tampered tokens
const payload = jwt.verify(token, secret, {
algorithms: ['HS256'],
issuer: 'your-app',
});Next.js Middleware Is Not Enough
Next.js middleware runs at the edge and is convenient for auth checks, but it is not a reliable sole auth layer. CVE-2025-29927 demonstrated that middleware could be completely bypassed via a spoofed x-middleware-subrequest header.
Always verify auth again in:
- Server Actions
- Route Handlers (
app/api/) - Data access functions / database queries
Middleware should be a convenience layer, not the only wall between an attacker and your data.
Server Actions Are Public Endpoints
Server Actions compile into public POST endpoints. Anyone can call them with curl. AI assistants frequently generate Server Actions that assume they're only called by the UI:
// BAD: no auth check, no input validation
'use server';
export async function deleteItem(id: string) {
await db.items.delete({ where: { id } });
}
// GOOD: validates input, authenticates, and authorizes
'use server';
export async function deleteItem(input: unknown) {
const parsed = schema.safeParse(input);
if (!parsed.success) return { error: 'Invalid input' };
const session = await auth();
if (!session?.user) redirect('/login');
// Authorize: verify ownership, not just login
await db.items.deleteMany({
where: { id: parsed.data.id, userId: session.user.id }
});
}Every Server Action needs three things at the top: 1. Input validation (Zod or similar runtime schema) 2. Authentication (verify the user is logged in) 3. Authorization (verify the user owns the resource)
API Route Handlers
Same rules apply to app/api/ route handlers. Every route handler is a public endpoint. Authenticate and authorize at the top of every handler.
Data Leakage to Client Components
Never pass entire database objects to Client Components. They may contain sensitive fields (hashed passwords, internal IDs, admin flags). Select only the fields the client needs:
// BAD: leaks all fields to the client
const user = await db.users.findUnique({ where: { id } });
return <UserProfile user={user} />;
// GOOD: select only needed fields
const user = await db.users.findUnique({
where: { id },
select: { name: true, avatarUrl: true }
});
return <UserProfile user={user} />;Use import 'server-only' at the top of data access modules to prevent them from being accidentally imported into Client Components.
Session & Token Storage
- Store tokens in
HttpOnly + Secure + SameSite=Laxcookies, not localStorage. - localStorage is accessible to any JavaScript on the page — a single XSS vulnerability exposes all tokens.
HttpOnlycookies are invisible to JavaScript and sent automatically by the browser.
Data Access & Input Validation
SQL Injection
Always use parameterized queries or ORM methods. Never concatenate user input into SQL strings:
// BAD: SQL injection via string concatenation
const result = await db.query(`SELECT * FROM users WHERE id = '${userId}'`);
// GOOD: parameterized query
const result = await db.query('SELECT * FROM users WHERE id = $1', [userId]);ORM Safety (Prisma)
Even with an ORM, injection is possible:
- Validate input types with Zod before passing to Prisma.
findFirstand similar methods are vulnerable to operator injection if unvalidated objects are passed as filter values. An attacker can send{ "email": { "contains": "" } }to match all records.
// BAD: raw request body passed directly to Prisma
const user = await prisma.user.findFirst({ where: req.body });
// GOOD: validate with Zod first
const schema = z.object({ email: z.string().email() });
const parsed = schema.parse(req.body);
const user = await prisma.user.findFirst({ where: { email: parsed.email } });- Never use `$queryRawUnsafe` or `$executeRawUnsafe` with user-supplied input. These bypass Prisma's parameterization entirely.
// BAD: raw SQL with user input
const results = await prisma.$queryRawUnsafe(
`SELECT * FROM users WHERE name = '${name}'`
);
// GOOD: use the safe raw query with parameters
const results = await prisma.$queryRaw`
SELECT * FROM users WHERE name = ${name}
`;Input Validation
Validate all external input at system boundaries using a runtime schema validator (Zod, Yup, Joi, etc.):
- API route handlers
- Server Actions
- Webhook handlers
- Form submissions
- URL parameters and query strings
Don't rely on TypeScript types alone — they're compile-time only and don't exist at runtime. An attacker sending a malformed request bypasses all TypeScript checks.
// TypeScript type provides NO runtime protection
type CreateUserInput = { name: string; email: string };
// Zod schema provides ACTUAL runtime validation
const CreateUserSchema = z.object({
name: z.string().min(1).max(100),
email: z.string().email(),
});Mass Assignment
Don't spread request bodies directly into database operations. An attacker can add unexpected fields:
// BAD: attacker can add { isAdmin: true, credits: 99999 }
await db.users.update({ where: { id }, data: req.body });
// GOOD: pick only allowed fields
const { name, email } = validated.data;
await db.users.update({ where: { id }, data: { name, email } });Database Access Control
This is the #1 source of critical vulnerabilities in vibe-coded apps. AI assistants routinely generate database schemas without proper access control, leaving entire tables exposed.
Supabase Row-Level Security (RLS)
Enable RLS on Every Table
Tables created via SQL Editor or migrations have RLS disabled by default. A table without RLS is fully readable and writable by anyone with the anon key (which is public). Run this in every migration to catch missed tables:
DO $$ DECLARE r RECORD;
BEGIN
FOR r IN SELECT tablename FROM pg_tables WHERE schemaname = 'public'
LOOP
EXECUTE format('ALTER TABLE public.%I ENABLE ROW LEVEL SECURITY;', r.tablename);
END LOOP;
END $$;Dangerous RLS Policies
Never use `USING (true)` or `USING (auth.uid() IS NOT NULL)` on SELECT/UPDATE/DELETE. These let any authenticated user access every row in the table. Always scope to the row owner:
-- BAD: any logged-in user can read all rows
CREATE POLICY "Users can view data" ON public.documents
FOR SELECT TO authenticated USING (true);
-- BAD: any logged-in user can read all rows
CREATE POLICY "Users can view data" ON public.documents
FOR SELECT TO authenticated USING (auth.uid() IS NOT NULL);
-- GOOD: users can only read their own rows
CREATE POLICY "Users can view own data" ON public.documents
FOR SELECT TO authenticated USING ((SELECT auth.uid()) = user_id);Missing WITH CHECK
Always include WITH CHECK on INSERT and UPDATE policies. Without it, a user can reassign row ownership or insert rows as another user:
-- BAD: user can UPDATE user_id to someone else's ID
CREATE POLICY "Users can update tasks" ON public.tasks
FOR UPDATE TO authenticated USING ((SELECT auth.uid()) = user_id);
-- GOOD: WITH CHECK prevents changing user_id
CREATE POLICY "Users can update tasks" ON public.tasks
FOR UPDATE TO authenticated
USING ((SELECT auth.uid()) = user_id)
WITH CHECK ((SELECT auth.uid()) = user_id);Sensitive Fields on User-Accessible Tables
If a profiles table lets users UPDATE their own row, they can set is_admin = true, credits = 99999, or subscription_tier = 'enterprise'. Fixes:
- Option A: Move sensitive fields to a
privateschema table not exposed via PostgREST. Access them throughSECURITY DEFINERfunctions. - Option B: Use column-level privileges:
REVOKE UPDATE ON profiles FROM authenticated;
GRANT UPDATE (display_name, avatar_url) ON profiles TO authenticated;Forgotten Related Tables
Junction tables, audit logs, and metadata tables often lack RLS even when the main table has it. Every table exposed via the REST API needs its own policies. If a table has RLS enabled but no policies defined, it blocks all access — which is safe but may cause subtle bugs.
SECURITY DEFINER Functions
SECURITY DEFINER functions bypass RLS entirely. If created in the public schema, they're callable via the REST API by anyone. Always:
- Keep them in a
privateschema - Set
SET search_path = '' - Validate all inputs inside the function
Storage Buckets
Storage buckets need their own policies. Without them, any authenticated user can upload, read, or delete any file. Scope uploads to the user's UID folder:
CREATE POLICY "Users upload to own folder"
ON storage.objects FOR INSERT TO authenticated
WITH CHECK (
bucket_id = 'avatars'
AND (storage.foldername(name))[1] = (SELECT auth.uid())::TEXT
);Firebase Security Rules
Default Rules Are Dangerous
Never ship these:
// BAD: world-readable and writable
allow read, write: if true;
// BAD: any logged-in user can access everything
allow read, write: if request.auth != null;Always validate ownership:
allow read, write: if request.auth.uid == userId;Field-Level Protection
Without restricting which fields users can modify, they can set isAdmin: true or credits: 99999:
// GOOD: restrict modifiable fields on UPDATE
allow update: if request.auth.uid == userId
&& request.resource.data.diff(resource.data)
.affectedKeys()
.hasOnly(['displayName', 'avatarUrl']);Subcollection Trap
Subcollections are NOT secured by parent rules. Each subcollection needs its own explicit rules. AI assistants frequently miss this.
Data Validation
Validate data types and sizes on writes:
allow create: if request.resource.data.displayName is string
&& request.resource.data.displayName.size() <= 50;Enforce server timestamps:
allow create: if request.resource.data.createdAt == request.time;Role Checks
Use custom claims (request.auth.token.role) instead of querying a users document. Custom claims can't be tampered with by the user and don't require extra reads.
Cloud Storage Rules
Must validate contentType, size, and path ownership. Without this, users can upload executables or store files in other users' paths.
Convex
- Every public
queryandmutationmust callctx.auth.getUserIdentity()and handle the unauthenticated case. - Mutations must verify ownership — checking auth is not enough. Verify the user owns the specific resource they're modifying.
- Functions only called internally must use
internalQuery/internalMutation/internalAction, notquery/mutation. Public functions are callable by anyone.
Deployment Security
Production Configuration
- Disable debug mode in production. Debug pages often leak stack traces, environment variables, and internal paths.
- Disable source maps in production. Source maps expose your entire source code to anyone who opens DevTools.
- Verify `.git` directory is not accessible in production. If
https://yoursite.com/.git/HEADreturns content, your entire source code and commit history (including any secrets ever committed) are exposed.
Environment Separation
Use separate environment variables for each environment in Vercel (or equivalent):
| Environment | Purpose |
|---|---|
| Production | Live users, real keys |
| Preview | PR previews, should use test/staging keys |
| Development | Local dev, uses local/test keys |
Preview deployments should never use production API keys, database credentials, or payment keys. A preview deployment is often accessible to anyone with the URL.
Security Headers
Set these headers on all responses:
Content-Security-Policy: default-src 'self'; script-src 'self'
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Frame-Options: DENY
X-Content-Type-Options: nosniff
Referrer-Policy: strict-origin-when-cross-origin
Permissions-Policy: camera=(), microphone=(), geolocation=()Adjust Content-Security-Policy based on your app's needs (e.g., if you use inline styles or load scripts from CDNs), but start restrictive and loosen as needed — not the other way around.
Pre-Ship Checks
Before deploying:
- Run
gitleaks detecton your repo to scan for leaked secrets in git history - Verify
.envfiles are in.gitignore - Confirm debug mode / verbose logging is disabled
- Check that error pages don't leak stack traces
- Verify CORS is configured to allow only your domains, not
*
CORS Configuration
- Never use
Access-Control-Allow-Origin: *on authenticated endpoints - Whitelist only your own domains
- Be careful with
Access-Control-Allow-Credentials: true— it must be paired with specific origins, not wildcards
Mobile Security (React Native / Expo)
No Secrets in the JavaScript Bundle
All API keys and secrets in the JavaScript bundle are extractable — even with Hermes bytecode compilation. The bundle is a file on the device that can be read, decompiled, and searched for strings.
react-native-configvalues are baked into the bundle at build time. They are not secret.EXPO_PUBLIC_values are baked into the bundle at build time. They are not secret.- Environment variables set via
eas.jsonorapp.config.jsthat end up in the JS bundle are not secret.
The only safe approach: use a backend proxy for all third-party API calls that require secret keys. The mobile app calls your server; your server calls the third-party API with the key.
// BAD: API key in the mobile app
const response = await fetch('https://api.openai.com/v1/chat/completions', {
headers: { 'Authorization': `Bearer ${OPENAI_API_KEY}` }
});
// GOOD: call your own backend, which holds the key
const response = await fetch('https://your-api.com/ai/chat', {
headers: { 'Authorization': `Bearer ${userSessionToken}` },
body: JSON.stringify({ message: userInput }),
});Secure Token Storage
- Use `expo-secure-store` (Expo) or `react-native-keychain` (bare React Native) for auth tokens.
- Never use `AsyncStorage` — it's unencrypted plaintext on disk. On a rooted/jailbroken device, tokens are trivially readable.
// BAD: plaintext on disk
await AsyncStorage.setItem('authToken', token);
// GOOD: encrypted in device keychain
await SecureStore.setItemAsync('authToken', token);Deep Link Security
Deep links (myapp://path?param=value) can be triggered by any app or website. They are an attack surface:
- Validate and sanitize all parameters. Never trust deep link input.
- Never include sensitive data in deep link URLs (tokens, passwords, user IDs that grant access).
- Don't perform destructive actions directly from deep link parameters without user confirmation.
Biometric Authentication
A simple boolean success check from biometric auth (isAuthenticated = true) can be hooked with tools like Frida on a jailbroken device. Proper biometric auth must use cryptographic verification:
1. Server sends a challenge (random nonce) 2. App signs the challenge with a hardware-backed key (Secure Enclave / Strongbox) 3. Server verifies the signature
This way, even if the biometric check is bypassed, the attacker can't forge the cryptographic signature.
Payment Security (Stripe)
Never Trust Client-Submitted Prices
The #1 payment vulnerability in vibe-coded apps: the price comes from the client. An attacker can set any amount, including $0.
// BAD: price comes from the request body
const session = await stripe.checkout.sessions.create({
line_items: [{
price_data: {
currency: 'usd',
unit_amount: req.body.price, // attacker controls this
product_data: { name: req.body.name },
},
quantity: 1,
}],
});
// GOOD: look up the price server-side
const product = await db.products.findUnique({ where: { id: req.body.productId } });
if (!product) return new Response('Not found', { status: 404 });
const session = await stripe.checkout.sessions.create({
line_items: [{ price: product.stripePriceId, quantity: 1 }],
});Use Stripe Price IDs (created via the Stripe dashboard or API) rather than constructing prices from your database. This way, prices are defined in Stripe and can't be manipulated.
Webhook Signature Verification
Stripe webhooks must have their signatures verified. This requires the raw request body — parsing the body as JSON first destroys the signature.
// Express: webhook route MUST use express.raw() BEFORE express.json()
app.post('/webhook', express.raw({ type: 'application/json' }), (req, res) => {
const sig = req.headers['stripe-signature'];
const event = stripe.webhooks.constructEvent(req.body, sig, webhookSecret);
// ... handle event
});
// Next.js App Router: use request.text(), NOT request.json()
export async function POST(request: Request) {
const body = await request.text();
const sig = request.headers.get('stripe-signature')!;
const event = stripe.webhooks.constructEvent(body, sig, webhookSecret);
// ... handle event
}Subscription Status Validation
Check subscription status server-side on every protected request using your database (kept in sync via webhooks). Do not rely on:
- A cached session value from login time
- A client-side flag
- A JWT claim that was set at token creation and never refreshed
Subscriptions can be cancelled, expire, or change tier at any time. Your database (updated via webhooks) is the source of truth.
Checkout Session Metadata
Validate that checkout session metadata (user ID, plan, etc.) was set server-side when creating the session, not passed from the client. If metadata comes from the client, an attacker can claim to be a different user or select a different plan.
Rate Limiting & Abuse Prevention
Where Rate Limiting Is Required
Every one of these endpoints needs rate limiting. AI assistants almost never add it:
- Auth endpoints — login, register, password reset, OTP verification, magic link. Without limits, attackers can brute-force passwords or enumerate accounts.
- AI API calls — Any endpoint that calls OpenAI, Anthropic, or similar. A single user can drain your entire monthly budget in minutes.
- Email / SMS sending — Attackers can use your app as a spam relay.
- File processing — Upload, resize, convert. CPU-intensive operations without limits enable denial-of-service.
- Webhook-like endpoints — Anything accepting external input at scale.
Don't Store Rate Limits in Public Tables
If rate limit counters live in a Supabase public table, users can reset their own counters via the REST API. Use:
- Upstash Redis — Serverless Redis with built-in rate limiting primitives
- Private schema table — Not exposed via PostgREST
- Middleware-level limiting — At the edge or API gateway
- In-memory stores — For single-server deployments (Redis for multi-server)
Combine Per-IP and Per-User Limiting
- IP-only limits are defeated by rotating IPs (trivial with VPNs or botnets)
- User-only limits are defeated by creating new accounts
- Use both together for effective protection
Billing Protection
- Set billing alerts on every cloud provider (AWS, GCP, Vercel, etc.)
- Set hard spending caps on AI API providers (OpenAI, Anthropic)
- Use per-user usage quotas with hard limits, not just soft warnings
- Monitor for anomalous usage patterns (sudden spikes, requests at odd hours)
Implementation Pattern
// Example: rate limiting with Upstash Redis
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 requests per minute
});
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? '127.0.0.1';
const { success } = await ratelimit.limit(ip);
if (!success) {
return new Response('Too many requests', { status: 429 });
}
// ... handle request
}Secrets & Environment Variables
Hardcoded Credentials
Never hardcode API keys, tokens, passwords, or credentials in source code. This includes:
- Strings that look like API keys in source files
- Connection strings with embedded passwords
- Private keys or certificates in the repo
If a secret was ever committed to Git history, consider it compromised — deleting the file doesn't remove it from history. The key must be rotated immediately. Run gitleaks detect to scan for leaked secrets.
Client-Side Environment Variable Prefixes
These prefixes cause env vars to be inlined into the client bundle at build time. Everything in the bundle is visible to anyone:
| Framework | Client Prefix | Danger |
|---|---|---|
| Next.js | NEXT_PUBLIC_ | Inlined into browser JS at build time |
| Vite | VITE_ | Inlined into browser JS at build time |
| Expo / React Native | EXPO_PUBLIC_ | Baked into the app bundle |
| Create React App | REACT_APP_ | Inlined into browser JS at build time |
What belongs client-side:
- Stripe publishable key (
pk_live_*,pk_test_*) - Supabase anon key
- Firebase client config (apiKey, authDomain, projectId)
- Public analytics IDs
What must NEVER be client-side:
- Supabase
service_rolekey (bypasses all RLS) - Stripe secret key (
sk_live_*,sk_test_*) - Any database connection string
- Any third-party API secret key
- JWT signing secrets
- OAuth client secrets
.gitignore
Ensure .env, .env.local, .env.*.local, and any file containing secrets is in .gitignore before the first commit. Check that .env.example or .env.sample files contain only placeholder values, not real keys.
Detection Tips
When auditing, search for:
- Files named
.envthat are tracked by git (git ls-files | grep .env) - Strings matching common key patterns:
sk_live_,sk_test_,AKIA,ghp_,glpat-,xoxb-,Bearer process.env.NEXT_PUBLIC_orimport.meta.env.VITE_referencing anything with "secret", "private", "service", or "key" in the name- Hardcoded URLs containing credentials (e.g.,
postgresql://user:password@host)
Related skills
FAQ
What is the core principle?
Never trust the client; validate prices, IDs, roles, and limits server-side.
Which area causes the most critical bugs?
Database access control, especially missing Supabase RLS or Firebase security rules.
Does it nitpick style issues?
No. It reports only genuine security issues prioritized by exploitability and impact.
Is Vibe Security safe to install?
skills.sh reports 2 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.