api-security — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited api-security (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
Web-app and API security overlap but are not the same. APIs ship with different defaults (CORS-permissive, no CSRF tokens, often no rate-limiting), different consumers (mobile apps, integrations, scripts — not just browsers), and a different attack surface (object IDs in URLs, JSON bodies, scoped tokens). The OWASP API Security Top 10 (2023 edition) is the canonical reference; this skill walks each item with concrete detection and fix patterns.
The #1 API vulnerability and not even close. Every endpoint that takes an ID and returns the corresponding resource must check the caller is allowed to see that specific object.
// Bad — any authenticated user reads any invoice
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoices.findUnique({ where: { id: req.params.id }});
res.json(invoice);
});
// Good — scoped to the requester's ownership
app.get('/api/invoices/:id', requireAuth, async (req, res) => {
const invoice = await db.invoices.findFirst({
where: { id: req.params.id, userId: req.user.id }
});
if (!invoice) return res.status(404).json({ error: 'not found' });
res.json(invoice);
});Detection in a codebase:
# Every parameterized route is a BOLA candidate. Walk them.
grep -rEn ":id|:slug|:uuid" src/routes src/api app 2>/dev/null
# Routes that fetch by primary key alone — high-suspicion pattern
grep -rEn 'findUnique\\(\\{\\s*where:\\s*\\{\\s*id:' --include='*.{ts,js}' . | headPatterns that make BOLA harder to introduce:
SET LOCAL app.current_user. See postgres-hardening.Auth on APIs has its own failure modes beyond web-app auth.
Authorization headerFor password-based API auth specifically:
auth-hardening/api/auth/loginTwo flavors:
// Bad — user can set their own role to admin
app.patch('/api/users/me', requireAuth, async (req, res) => {
const updated = await db.users.update({
where: { id: req.user.id },
data: req.body, // ☠️ accepts arbitrary keys including 'role'
});
res.json(updated);
});
// Good — explicit allowlist of editable fields
const PROFILE_FIELDS = ['displayName', 'avatarUrl', 'timezone'];
app.patch('/api/users/me', requireAuth, async (req, res) => {
const data = Object.fromEntries(
Object.entries(req.body).filter(([k]) => PROFILE_FIELDS.includes(k))
);
const updated = await db.users.update({ where: { id: req.user.id }, data });
res.json(updated);
});Better: validate with a schema library (Zod, Valibot, class-validator) and let it reject unknown keys.
API returns the whole DB row when the consumer needs three fields. Browser-facing this leaks PII; mobile-app-facing the same leak is in production traffic forever.
// Bad
const user = await db.users.findUnique({ where: { id }});
return user; // includes password_hash, email_verified_at, internal_notes, ...
// Good — explicit projection
const user = await db.users.findUnique({
where: { id },
select: { id: true, displayName: true, avatarUrl: true },
});For GraphQL: the schema is the contract. Treat every field on every type as "is this safe to expose to this kind of caller?"
DoS via expensive operations. APIs are easier to DoS than web apps because each request can deliberately ask for more work.
Defenses, all of which you need:
// Express + express-rate-limit
import rateLimit from 'express-rate-limit';
app.use('/api/', rateLimit({
windowMs: 60_000, max: 100,
standardHeaders: 'draft-7', legacyHeaders: false,
}));
app.use('/api/auth/login', rateLimit({ windowMs: 60_000, max: 5, skipSuccessfulRequests: true }));app.use(express.json({ limit: '100kb' })). The default of 1MB is too generous for most APIs.limit=10000 is not pagination, it is "give me everything." Cap server-side at 100 or so.graphql-depth-limit + graphql-query-complexity. Without these, { user { friends { friends { friends { ... } } } } } brings the DB down.Admin / privileged endpoints reachable by non-admins. Common when:
/api/admin/ but the auth check is at middleware level only — and a route was added later that bypasses middlewareDetection:
grep -rE '/api/admin' src 2>/dev/null
grep -rE 'role.*admin|isAdmin|requireAdmin' src 2>/dev/null
# Every admin route should have the role check in the handler, not just middlewareSame rule as elsewhere in this repo: never rely on middleware alone. Each privileged handler does its own check.
A specific subclass of resource consumption: even with rate limits, an attacker can scrape your "show me product X price" endpoint at 1 RPS for weeks. The data eventually leaves your system.
Examples in the wild:
Mitigations require business-context decisions:
// Bad — fetches whatever the user says, including internal services
app.get('/api/preview', async (req, res) => {
const url = req.query.url as string;
const r = await fetch(url);
res.send(await r.text());
});Attacker submits http://169.254.169.254/latest/meta-data/ (AWS metadata) or http://localhost:6379/ (your Redis) and your server happily proxies.
Fix:
https://known-customer-domains.example.com/*10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 127.0.0.0/8, 169.254.0.0/16, ::1, fc00::/7)For URL-fetching features, consider routing through a dedicated proxy service (e.g. Cloudflare Workers) that enforces the rules outside the app's trust boundary.
A grab bag, but the high-frequency ones:
Access-Control-Allow-Origin: * on endpoints that return user data, or echoing back the Origin header unchangedStrict-Transport-Security, X-Content-Type-Options, etc. See site-server-audit./debug, /__profile__, /_admin/inspect)Detection:
grep -rEn 'cors.*\\*|Access-Control-Allow-Origin.*\\*' --include='*.{js,ts,py}' .
grep -rEn 'NODE_ENV.*development|DEBUG.*True|app\\.debug' --include='*.{js,ts,py}' .
curl -sI https://api.example.com | grep -iE 'strict-transport|x-content-type|x-frame'The API you forgot about is the one that gets exploited:
endpoints** still live after the app moved to /v2/*`Mitigations:
dig, subfinder, your DNS records, your reverse-proxy config/api/*Your API calls third-party APIs (Stripe, SendGrid, OpenAI, weather, etc.). Treat third-party responses as untrusted input.
rejectUnauthorized: false in productionsecret-hygiene)GraphQL adds attack surface even when REST patterns are addressed:
@auth directives are goodDataLoader is essentially mandatoryimport depthLimit from 'graphql-depth-limit';
import { createComplexityRule } from 'graphql-query-complexity';
const server = new ApolloServer({
schema,
validationRules: [
depthLimit(10),
createComplexityRule({ maximumComplexity: 1000, /* ... */ }),
],
introspection: process.env.NODE_ENV !== 'production',
});/users/me/invoices/:id instead of /invoices/:id. Forces the design to think about who owns the resource.{ error: { code, message } } with sanitized messages, codes mapped to client behavior/api/v1/, /api/v2/. Forces deprecation discipline.Walk every endpoint against:
*?Anything you cannot answer → finding.
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.