Skip to Content
QA Docs & Abangku 0.1.0 is released. Read more →

Security

11.1 Environment variable rules

Every environment variable is classified as either public/client-exposed or server-only, and that boundary is treated as a security boundary, not a naming convention.

  • Public/client-exposed variables are anything the framework bundles into client-side code (e.g. a NEXT_PUBLIC_-prefixed variable). Treat anything with this prefix as visible to anyone who opens dev tools, because it is. Only ever put values here that are safe to be fully public: a public API base URL, a feature flag that has no security implication, an analytics key meant to be public.
  • Server-only variables are everything else: database connection strings, API keys for external providers, signing secrets. These are read only in server-side code and never referenced from a client component or bundled into client-side output.

Rule: before adding a new environment variable, decide which category it belongs to and name/scope it accordingly. Adding a secret to a public-prefixed variable “temporarily” is a security incident waiting to happen, not a shortcut; if it happens, rotate the secret immediately, a public bundle can be cached or archived long after the code is fixed.

11.2 Centralized authentication

Session and token verification happens in one centralized place, middleware or an equivalent shared entry point, not duplicated per route or procedure. See 6.2 for where this sits in the required middleware stack.

  • Every route or procedure that requires authentication goes through the same verification path. A handler never implements its own ad hoc token check.
  • Authorization (what an authenticated user is allowed to do) is layered on top of authentication (who the user is) using the current stack’s authorization library (CASL, see 2.2), itself centralized rather than reimplemented per feature.

Rule: if a new route needs a variation on the standard authentication check, extend the centralized mechanism to support that variation, don’t write a parallel check in the route itself. Two authentication code paths are a bug waiting for one of them to fall out of sync with the other.

11.3 Input validation and sanitization

Every input is validated and sanitized at the server boundary, using the shared schema library (Zod, see 9.3), regardless of what validation already happened on the client.

  • Client-side validation exists for user experience: fast feedback, fewer round trips. It is never treated as the actual security guarantee, since a client can be bypassed entirely (a direct API call, a modified request).
  • Server-side validation is the only validation that counts as enforcement. This applies to every input source: request bodies, query parameters, headers, and file uploads (see 9.5).

Rule: an endpoint that trusts client-side validation and skips server-side validation “because the client already checks it” is a vulnerability, not an optimization. Validate at the server boundary every time, with no exceptions for internal-only or “trusted” clients.

11.4 Secrets management

Secrets for external integrations (API keys, signing secrets, connection strings) are managed through the current stack’s secrets store (Azure Key Vault, see 2.2):

  • Never in code. A secret does not appear in source code, a config file committed to the repository, or a hardcoded fallback value “for local dev.” Local development reads from the same secrets mechanism (or a local .env file that is gitignored, never committed) as production.
  • Never logged. A secret does not appear in a log line, an error message, or a stack trace. See 6.1’s logging baseline, which already requires this; this subsection is why that requirement exists.
  • Rotation is possible without a code deploy. Because secrets are read from the secrets store at runtime, not baked into a build, rotating a compromised secret is an operational action, not a release.

Rule: if a secret is ever found in source control, in a log, or in an error response, treat it as compromised and rotate it immediately, don’t just remove it from the offending location. Removing the exposure does not undo the exposure.

11.5 Middleware standards

These are the specific policies behind the required middleware baseline in 6.2. That subsection says every service must have these middlewares; this subsection defines what each one enforces.

  • Rate limiting on sensitive operations (login, OTP verification, search, and any other operation prone to abuse or scraping) is the engineering team’s responsibility, implemented at the application layer, even where infra-level protection (a WAF, a platform-level rate limiter) also exists. Infra-level protection is a second layer, not a substitute for this one.
  • CORS policy is explicit and scoped to known origins. A wildcard CORS policy (*) is not acceptable for any endpoint that handles authenticated requests or sensitive data.
  • Request size limits are enforced on every endpoint that accepts a body, sized to what that endpoint legitimately needs, not left at a framework default that may be far larger than necessary.
  • Correlation/request IDs are assigned per request (see 6.2’s observability format) so a single request can be traced across every service it touches, which matters for security investigation as much as for debugging: reconstructing what happened during an incident depends on being able to follow one request’s trail end to end.
  • Auth middleware is centralized, per 11.2 above, and is part of this same required stack, not a separate concern.

Rule: these five are not optional per service based on perceived risk. Every service that accepts external requests has all five, regardless of how low-risk the service seems, because the cost of adding them later, after an incident, is far higher than the cost of including them from the start.

11.6 Session and token lifecycle

11.2 covers where a token is verified; this subsection covers how long it’s valid, how it’s refreshed, and how it’s revoked.

  • A short-lived access token, backed by a longer-lived refresh token. The access token used to authenticate individual requests expires quickly (on the order of minutes to an hour), limiting how long a leaked access token remains useful. The refresh token, used only to obtain a new access token, is longer-lived and never sent on ordinary requests, only to the token-refresh endpoint.
  • Refresh happens transparently, before the access token actually expires, not reactively after a request already failed with an auth error. A client tracks its access token’s expiry and refreshes ahead of it, so an expired-token failure on an ordinary request is the exception, not the expected flow.
  • A refresh token is revocable, and revocation actually takes effect. Signing out, a password change, or an administrator revoking access invalidates the refresh token immediately, an already-issued access token may remain valid for the remainder of its own short lifetime (that’s the tradeoff for keeping access tokens stateless and cheap to verify), but no further refresh is possible once the refresh token is revoked.
  • A revoked or expired refresh token requires a full re-authentication, not a silent, indefinite renewal chain. A refresh token is not itself refreshed forever; it has its own bounded lifetime, after which the user re-authenticates through the primary identity provider (2.2).

Rule: a client-side integration that stores or forwards a refresh token anywhere other than the mechanism that owns the refresh flow (never in localStorage, per 9.7) is a security review flag, a leaked refresh token is a materially worse exposure than a leaked access token, since it grants ongoing access, not just access for the remainder of one short window.

11.7 CSP and XSS defense

11.3 covers validating and sanitizing input at the server boundary. This subsection covers the complementary defense on the output side: preventing injected content from executing as script in the browser, even if something slipped past input handling.

  • A Content-Security-Policy header is set on every response, restricting which sources scripts, styles, and other resources can load from. A CSP that allows arbitrary inline scripts or arbitrary external script sources defeats most of its own purpose; scope it as tightly as the actual application needs, not as a formality.
  • Never render unsanitized user-generated content as raw HTML. The framework’s default templating already escapes output; an explicit opt-out of that escaping (rendering raw HTML from a string, e.g. via dangerouslySetInnerHTML or an equivalent) is only acceptable for content that’s been through a dedicated sanitization step (stripping or allow-listing safe tags/attributes), never for raw user input rendered as-is.
  • Rich content (Markdown, user-authored HTML) goes through the approved rendering pipeline (react-markdown with remark-gfm and rehype-raw, see 2.2), which is responsible for safe rendering, rather than each feature hand-rolling its own sanitization approach.
  • A CSP violation is monitored, not just set and forgotten. Where the platform supports it, CSP violation reports feed into the existing error-reporting pipeline (Sentry, see 5.19), so an unexpected script source attempting to load, whether from a misconfiguration or an actual attack, is visible rather than silently blocked and never noticed.

Rule: a PR that opts out of the framework’s default output escaping (rendering raw HTML from a dynamic string) without going through the approved sanitization pipeline is a review flag, the same severity as skipping server-side input validation in 11.3; both are the same class of vulnerability, one on the way in, one on the way out.

11.8 Injection (SQL and NoSQL)

An injection vulnerability is user input being interpreted as part of a query or command instead of as data, letting an attacker read, modify, or delete data outside what the application intended to expose. The concrete authoring rule already lives in 6.7: every query is parameterized or built through the ORM/query-builder’s own parameter binding, never through string concatenation or interpolation. This subsection states why that rule exists and where it also applies beyond a typical query.

  • This applies to NoSQL, not just SQL. A MongoDB query built from an unsanitized object (a client-supplied filter object passed directly into a query, allowing operators like $where or $ne to be injected) is the same class of vulnerability as SQL injection, even though there’s no SQL syntax involved. Never pass a client-supplied object directly as a query filter, only pass through fields the schema (see 9.3) explicitly allows.
  • It also applies outside the obvious query path. A sort field, a field name used in a dynamic projection, or a value interpolated into an aggregation pipeline stage are all injection surfaces if built from unsanitized input, not just the parts of a query that look like a search filter.
  • Server-side validation is what actually prevents this, per 11.3: validating shape and type at the boundary is necessary, but parameterization is what prevents the injection specifically, validating a string’s length doesn’t stop it from containing an operator injection if it’s later concatenated or passed through unsanitized.

Rule: a query, filter, or aggregation stage built from unsanitized input, in any datastore, is a security review blocker at the same severity as the query-standards violation in 6.7; this is that rule’s security justification, not a separate, optional check.

11.9 CSRF (cross-site request forgery)

CSRF tricks an authenticated user’s browser into sending a request the user didn’t intend, relying on the browser automatically attaching session cookies to any request to the site that set them, regardless of which page the request originated from.

  • SameSite cookies are the primary defense. Session and authentication cookies (see 11.6) are set with SameSite=Lax or SameSite=Strict, so the browser does not attach them to a cross-site request in the first place. SameSite=None is never used for an authentication cookie without a specific, documented reason (a legitimate cross-site embedding need), since it disables this protection entirely.
  • State-changing requests use a method that isn’t automatically sent cross-site without a preflight, or carry an explicit anti-CSRF token, for any mutation not already covered by SameSite cookies (e.g. a form submission on a page intentionally embedded cross-site). A GET request never triggers a mutation, per ordinary REST/HTTP semantics, this is what keeps a mutation from being triggerable by something as simple as an <img> tag pointed at the URL.
  • CORS is not a CSRF defense. A permissive CORS policy controls whether a cross-origin script can read a response, it does nothing to stop a cross-origin form submission or navigation from sending a request with cookies attached. Don’t rely on the CORS policy in 11.5 to cover this; they’re separate concerns with separate defenses.

Rule: an authentication or session cookie set without an explicit SameSite attribute, or with SameSite=None and no documented reason, is a review flag. This is a default that must be set deliberately, not left to whatever the framework or library happens to default to.

11.10 SSRF (server-side request forgery)

SSRF happens when the server itself is tricked into making a request to a URL an attacker controls or influences, potentially reaching internal services, cloud metadata endpoints, or other resources that aren’t meant to be reachable from outside.

  • Any outbound request whose target URL is influenced by user input is a potential SSRF surface: a webhook URL a user registers, a “fetch this image/link” feature, a URL passed to a file-import feature (see 9.5). Treat the destination as untrusted input, not just the payload.
  • Validate and allowlist the destination, don’t just check it looks like a URL. A URL that parses successfully can still resolve to an internal IP address, a cloud metadata endpoint (169.254.169.254), or localhost. Resolve the hostname and reject requests to private/link-local/loopback address ranges before making the request, checking the URL string alone is not sufficient, since a hostname can resolve differently at request time than it appeared to at validation time (a TOCTOU case, see 8.8).
  • Route user-influenced outbound requests through a dedicated, restricted path, not the same general-purpose external API wrapper used for trusted, developer-configured integrations (see 7.4). A wrapper built for a known, trusted provider is not the same trust boundary as a feature that fetches a URL a user supplied.

Rule: a feature that makes an outbound request to a user-supplied or user-influenced URL is not complete without destination validation against internal/private address ranges; this is a review blocker in the same class as missing input validation at a server boundary (11.3), the untrusted input here is a destination, not a payload.

11.11 Authorization policy design

11.2 establishes that authorization is layered on authentication using CASL. This subsection covers how permissions are actually modeled.

  • Resource and attribute-based, not role-only. A permission is defined in terms of the resource and its attributes (can this user edit this specific record, based on ownership, tenant, or another attribute of the record), rather than only a coarse role check (can this user edit anything of this type). A role-only check is insufficient the moment “can edit their own records” and “can edit any record” need to be different permissions.
  • A permission rule is defined once, in the shared CASL ability definition, and evaluated identically on the client (for UI gating, hiding an action the user can’t perform) and the server (for actual enforcement), per the isomorphic rationale already stated in 2.2. The client-side check is a UX convenience; the server-side evaluation of the same rule is what actually enforces it, consistent with 7.1’s rule that a client-side decision is never trusted as the final word.
  • A new resource type gets its permission rules defined at the same time it’s introduced, not left to an implicit “no rule means denied” or, worse, an implicit “no rule means allowed” default. State explicitly, in the ability definition, who can do what with a new resource type as part of building it, not as a follow-up.

Rule: an endpoint or procedure that performs an authorization check by hand (an inline if (resource.ownerId === user.id)) instead of through the shared CASL ability definition is a review flag, the same class of drift risk as the parallel-authentication-check problem already named in 11.2: a hand-written check and the centralized policy will eventually disagree.

11.12 Audit logging for sensitive actions

This is distinct from the general observability format in 6.2: that subsection covers what every request logs for debugging and tracing; this subsection covers a narrower, security-specific requirement, a durable record of who did what to sensitive data or permissions.

  • A sensitive action is logged with who, what, and when, in a durable, queryable record, distinct from ordinary application logs: a permission change, access to another user’s data by an administrator, a record deletion, an export of sensitive data. This is a security and compliance record, not a debugging aid, and is retained independently of the general log retention policy.
  • The audit record is append-only and not editable by the action it records. A user who performs a sensitive action cannot alter or delete the audit entry that records it, an audit trail a bad actor could edit isn’t a trail.
  • Audit records never contain the secrets they might otherwise reference. An audit entry for “password changed” doesn’t include the password, per the same never-logged discipline in 11.4, it records that the action occurred, not the sensitive value involved in it.

Rule: a new feature performing a sensitive action (defined above) with no corresponding audit record is incomplete, the same standard this document already applies to a missing error state in the 4.2 checklist. If a future incident or compliance question asks “who did this and when,” the answer must already exist, not need to be reconstructed after the fact from incomplete application logs.

11.13 PII and data classification

Not all data carries the same risk, and this document’s other rules (logging, caching, audit trails) apply differently depending on what kind of data is involved. This subsection states the classification those other rules assume.

  • Classify a field as PII (personally identifiable information) if it can identify a specific person, alone or combined with other data held about them: a name, an email address, a phone number, a physical address, a government ID number, and any field that functions as a stable identifier tied to one real person. When in doubt, treat a field as PII, the cost of over-classifying is mild inconvenience; the cost of under-classifying is a real exposure.
  • PII is never logged in full, at the observability level required in 6.2: a log line references a record by its ID, not by embedding the PII fields themselves, the same discipline already required for secrets and full request bodies in that subsection.
  • PII in an export or a third-party integration is deliberate, not incidental. A data export (a CSV download, a report) or a payload sent to a third-party provider (7.4) includes only the PII fields the export or integration actually needs, not every field on the underlying record by default, because it was convenient to serialize the whole object.
  • PII is included in the audit trail requirement (11.12) for access, not just for modification: an administrator viewing another user’s PII is a sensitive action worth an audit record, not only changing it.

Rule: a new field storing personal data is classified as PII at the point it’s added to a schema (see the data modeling discipline in 6.8), not classified retroactively after it’s already been logged, exported, or sent to a third party in a way this subsection would have prevented.

11.14 Broken access control and IDOR

Broken access control, an authenticated user reaching data or actions they shouldn’t be able to, is consistently the most common real-world vulnerability class (OWASP’s own Top 10 has ranked it first for years), and it’s rarely a missing-authentication bug, it’s almost always a missing or incomplete authorization check on an endpoint that otherwise works correctly.

  • Every endpoint that takes a resource identifier checks that the authenticated caller is actually allowed to access that specific resource, not just that they’re authenticated at all. An endpoint that returns /orders/:id for any valid, authenticated request regardless of whose order it is, is an Insecure Direct Object Reference (IDOR): swapping the ID in the URL exposes another user’s data with no further exploit needed.
  • This is the concrete case the resource/attribute-based authorization model in 11.11 exists to prevent: a role check alone (“is this user allowed to view orders”) passes for every authenticated user, an IDOR only gets caught by an ownership/attribute check (“is this user allowed to view this order”) evaluated against the specific resource being requested, not the resource type in the abstract.
  • Authorization is checked on every operation on a resource, not just the read. A record’s read, update, and delete paths each need their own ownership/attribute check; a feature that correctly scopes reads to the owner but forgets the same check on the update or delete endpoint has an access control gap that’s easy to miss because the read path “looks” secure.
  • A nested or related resource inherits the same scoping. A comment on an order, a line item on an invoice, a document in a folder, each needs its own check that the parent resource is also one the caller is authorized for, not just that the nested resource’s own ID exists, scoping only the top-level resource and assuming everything nested under it is automatically safe is a common way this gap gets introduced.

Rule: an endpoint accepting a resource ID in the path, query, or body is not complete without an explicit test proving a different, authenticated user cannot access that resource by ID alone, per Section 13’s testing requirements. This is not a theoretical edge case, it is the single most common real-world access control failure, and it’s caught by a straightforward test if that test is written.

11.15 Preventing privilege escalation

Privilege escalation is an authenticated user gaining permissions they were never granted, either by making themselves more privileged (vertical escalation, a regular user becoming an admin) or by acting as a different user at the same privilege level (horizontal escalation, overlapping with the IDOR case in 11.14 above).

  • A user can never set their own role or permission level. Any field that determines privilege (a role, a permission flag, a tenant admin designation) is writable only through a dedicated, separately-authorized administrative action, never as a field the user’s own profile-update or account-settings endpoint accepts. An update endpoint that accepts an object and writes every field the client sent, without an explicit allow-list of which fields that endpoint is actually permitted to change, is how this gap gets introduced silently: mass-assignment of an unintended field, including a privilege field, through an endpoint that was only meant to let a user change their display name.
  • A privileged action re-verifies authorization at the point of the action, not just at the start of a session. A permission granted or revoked mid-session takes effect on the next privileged action, not only after the user’s next login, an authorization check cached for the lifetime of a session is a window where a revoked permission is still effectively honored.
  • Elevation of privilege is itself a sensitive action, logged per the audit requirement in 11.12: who granted a permission, to whom, and when, is exactly the kind of record needed to investigate a privilege-escalation incident after the fact.
  • A JWT or session token’s claims are trusted only as of the time they were issued. A short-lived access token (11.6) limits how long a token issued before a permission change remains valid with the old permissions baked in; don’t extend token lifetimes casually without considering that this is exactly the window a revoked or changed permission remains effectively active.

Rule: a mutation endpoint that writes any privilege-affecting field needs an explicit allow-list of writable fields, reviewed specifically for whether a privilege field is on that list and, if so, whether that’s actually intended. A generic “update this record” endpoint with no field allow-list is a mass-assignment vulnerability waiting for someone to send an extra field in the request body.

11.16 Penetration testing preparation

This subsection is about making the codebase and its documentation ready to be tested, whether that’s an internal exercise or an external penetration test, not about performing the test itself.

  • This document is a legitimate pentest input. A reviewer or tester auditing this codebase should be able to work from Section 11 as a stated security baseline, what’s supposed to be true (centralized auth, resource-scoped authorization, input validation at the boundary, secrets never in code or logs), and test specifically for where the actual code deviates from what this document claims.
  • Every finding maps to a rule already stated here, or reveals a gap in this document. A pentest finding that violates an existing rule (an endpoint missing the IDOR check required by 11.14, a secret found in a log despite 11.4) is a straightforward defect, fixed and reviewed like any other bug. A finding that doesn’t map to an existing rule is a genuine gap in this standard, propose closing it through the normal process (1.3), the same way any other real gap discovered in practice gets folded back into the document.
  • Findings and remediation are tracked the same way any other security-relevant work is, with the severity and urgency matching what this document already applies to comparable issues, an unfixed high-severity access control finding is treated with the same urgency as the unresolved dependency vulnerability finding in 6.13, not left open indefinitely because it came from an external report instead of an internal review.
  • A test environment used for pentesting never contains real production data. Test accounts, seeded data, and any data used to exercise the system during testing is synthetic, consistent with the seed-data conventions this document expects for local development and testing generally, not a copy of production data that would turn a test finding into an actual data exposure.

Rule: a penetration test or security audit finding is not considered resolved until it’s addressed the same way a standards violation would be, fixed, reviewed, and if it reveals a genuine gap in this document, that gap is closed here too, so the same class of finding doesn’t recur silently on the next audit.

Thanks to Bagas Suryo A. and Kenji Putra for the recommendations of penetration testing inputs.