Backend Development - Web Security

The privacy shortcuts junior backend devs regret later

Junior developers are usually taught to add more security signals, stricter controls, and broader monitoring. My position is less comfortable: many teams over-secure the wrong surface and under-protect user privacy because the hidden costs appear after launch. The dangerous lesson is that “secure” choices can create breach material when they collect, retain, or expose too much data.

More logging often makes the incident worse

Most teams discover the logging trade-off after the first production incident, because debugging pressure rewards “log everything” while privacy damage appears later in access reviews, subpoenas, support tickets, and breach reports. I prefer sparse, structured, short-lived logs for web applications, because a smaller dataset is easier to protect and less likely to contain passwords, reset tokens, session identifiers, email contents, or OAuth authorization codes.

Web Security Best Practices for Modern Software Development is useful as a checklist, but I disagree with any reading of it that treats more observability as automatically safer, because observability without minimization turns logs into a second database with weaker permissions.

A junior developer may add request and response logging in Express, Django, Rails, Spring Boot, or FastAPI to help reproduce bugs. That feels responsible until a failed login request stores a password typo, a payment redirect stores a one-time code, or a GraphQL resolver logs an entire profile object. The security team then has to protect application logs like production data, because attackers do not care whether a secret came from PostgreSQL, S3, Elasticsearch, Loki, or CloudWatch Logs.

Verizon’s 2024 Data Breach Investigations Report says 68% of breaches involved a non-malicious human element, a figure drawn from its incident corpus rather than a universal law. That number matters because excessive log access gives normal employees more chances to make irreversible mistakes. You cannot train away every copy-paste accident, so design should make sensitive values unavailable by default.

I would not enable full request-body logging in production, even behind role-based access control, because the first outage will pressure someone to widen access and the log store will silently become the most convenient place to search private data. I would log correlation IDs, route names, status codes, latency buckets, tenant IDs only where contractually safe, and security event types such as password_reset_requested without the reset URL.

Use OpenTelemetry 1.27 semantic conventions for trace names, but keep attributes boring and predictable. Use Sentry’s beforeSend hook, Datadog Sensitive Data Scanner, or Elastic ingest pipelines to redact Authorization, Cookie, Set-Cookie, X-API-Key, and query parameters named token, code, or state. Redaction is not enough by itself, because new fields appear faster than rules are updated, but it catches common leaks before humans see them.

A 30-day production log retention window is a policy knob, not a magic safety number. It is often enough for customer support and incident reconstruction, while it limits the blast radius compared with keeping searchable logs for a year. If your team truly needs 180 days for fraud investigation, store a separate security event stream with explicit fields rather than preserving raw application payloads.

Strong authentication can quietly punish privacy

Authentication improvements often arrive as tickets with obvious acceptance criteria: add MFA, reduce account takeover, block suspicious logins, and store more device information. The trade-off is that every “risk signal” can become behavioral tracking if the team does not define boundaries. IP address, user agent, screen size, timezone, device fingerprint, geolocation, failed-login cadence, and WebAuthn credential metadata can all be useful, but collecting all of them forever is a privacy decision disguised as security engineering.

WebAuthn Level 3 and FIDO2 are strong defaults for phishing-resistant login, because the private key stays on the authenticator and the browser verifies the relying party ID. That is better than SMS codes for high-risk accounts, because SMS depends on the phone network and is vulnerable to SIM swap and interception. The cost is support complexity: account recovery becomes harder, and junior engineers may be asked to build dangerous bypasses after the first locked-out executive complains.

NIST SP 800-63B recommends a minimum of 8 characters for user-chosen memorized secrets, which is a standard-published floor rather than a target. I would accept longer passphrases, block known-compromised passwords with Have I Been Pwned k-anonymity lookup, and avoid forced periodic password rotation for normal users, because rotation often produces predictable variants like Summer2025! rather than better secrets.

For password hashing, Argon2id from RFC 9106 is the choice I reach for before bcrypt in new systems, because it is memory-hard as well as CPU-expensive. Parameters such as 64 MiB of memory, 3 iterations, and 1 lane are values to tune on your own hardware; they are not a badge of seriousness. A login endpoint that takes 900 ms under normal load might be acceptable for administrators but painful for consumers, and an overly expensive hash can become a denial-of-service multiplier.

Risk-based authentication is the place where teams most often overreach. A model that stores precise location history may block suspicious sign-ins, but it also creates a sensitive movement record. A simpler rule such as “challenge on new country plus new device cookie” loses some detection power, yet it reduces the amount of personal data retained. That trade-off is usually worth it for ordinary SaaS products, because most account compromise prevention comes from phishing-resistant MFA, good session handling, and breached-password checks rather than surveillance-grade profiling.

Token convenience becomes a revocation problem

The most expensive security mistake I see junior developers inherit is the casual choice of tokens. JSON Web Tokens, defined in RFC 7519, are convenient because services can verify them locally with a public key, but they are painful to revoke once issued. Opaque server-side sessions are less fashionable, yet they often protect users better because the server can delete or rotate session state immediately.

Here is the explicit comparison. JWT access tokens win when multiple services need low-latency verification without calling a central store, such as an API gateway validating a short-lived token signed with ES256. Their cost is revocation complexity, key rotation discipline, and the temptation to stuff private claims into a bearer token that may be copied into logs. Opaque Redis-backed sessions win when the app is mostly server-rendered or calls a small number of APIs, because logout, device management, and incident response are straightforward. Their cost is a dependency on Redis availability, cross-region replication design, and slightly more server-side state.

My default for a junior team building a normal web app is an HttpOnly, Secure, SameSite=Lax cookie containing an opaque session ID, because browsers already have cookie handling and the server can revoke the session on password change. SameSite=Lax is not perfect for every embedded flow, but it blocks many cross-site request contexts while preserving ordinary top-level navigation. SameSite=None should require Secure and an explicit reason, because third-party cookie contexts expose more tracking and CSRF complexity.

If you do use OAuth 2.1-style authorization code flow with PKCE, keep access tokens short-lived and boring. A 10-minute access token lifetime is a starting value to test, not a universal rule; it narrows replay damage while avoiding constant refresh traffic. Refresh tokens should be rotated and stored like secrets, because a long-lived refresh token is effectively a password with API privileges.

OpenID Connect ID tokens are another common privacy trap. They are meant for the client to learn authentication facts, not to become a portable user profile. Do not include address, phone number, internal role history, billing status, or feature flags in an ID token unless a relying party genuinely needs them, because every additional claim increases exposure wherever the token is stored, inspected, cached, or logged.

Browser protections fail when teams ship them as theater

Security headers are valuable, but teams often add them late as copy-pasted theater. A Content Security Policy can stop injected scripts from running, but only if it matches how the app actually loads JavaScript. HSTS can prevent protocol downgrade attacks, but only if the team understands subdomains and local development. Referrer-Policy can reduce data leakage, but it will not save a page that puts secrets in URLs.

The following Express and Helmet example runs after npm install express helmet. It is intentionally strict but not maximal, because a policy that breaks production will be disabled during an outage.

const express = require("express");
const helmet = require("helmet");

const app = express();
app.use(helmet({
  contentSecurityPolicy: {
    directives: {
      "default-src": ["'self'"],
      "script-src": ["'self'"],
      "object-src": ["'none'"],
      "base-uri": ["'none'"]
    }
  },
  hsts: { maxAge: 15552000, includeSubDomains: true }
}));
app.get("/", (_, res) => res.send("ok"));
app.listen(3000);

The HSTS maxAge value above is 15,552,000 seconds, a six-month setting to review before rollout. I would not set the preload directive on day one, because browser preload lists are intentionally sticky and a forgotten subdomain or legacy HTTP endpoint can become a production incident. HSTS preload is excellent after inventory, but it is reckless before inventory.

CSP Level 3 is worth learning early because it forces you to see your front-end supply chain. Avoid unsafe-inline for scripts unless you have a migration plan, because it allows the very inline script execution CSP is usually meant to block. Nonces and hashes are better, but they cost build-system work in Vite, Next.js, Rails import maps, or server templates. Trusted Types can reduce DOM XSS in Chromium-based browsers, but they cost refactoring time because unsafe sinks such as innerHTML must pass through approved policies.

Privacy leaks also hide in “secure” URLs. A password reset link in a query string can land in proxy logs, browser history, analytics tools, and the Referer header. Use short-lived tokens, consume them once, and send Referrer-Policy: strict-origin-when-cross-origin or stricter. The exact policy depends on product needs, but leaking a full reset URL to a third-party script is hard to defend because the user never consented to that transfer.

TLS 1.3 from RFC 8446 should be the floor for modern public endpoints where your platform allows it, because it removes obsolete cipher negotiation paths and improves handshake security. Still, TLS does not make data collection harmless; it protects data in transit, not from your own dashboards, backups, error trackers, or administrators.

Automated scanners find bugs, but they also create false confidence

Web Security Best Practices for Modern Software Teams correctly pushes shared responsibility, but I would add that shared responsibility fails when every tool alert is treated as equal, because junior developers then learn to silence noise instead of reducing risk.

Use scanners, but assign them jobs they can actually perform. Dependabot is good at opening pull requests for vulnerable GitHub dependencies, but it cannot tell whether the vulnerable function is reachable. npm audit –omit=dev reduces noise for production Node.js packages, but it can miss bundled code and runtime configuration flaws. Semgrep 1.x with –config p/owasp-top-ten catches recognizable patterns, but custom data flows need project-specific rules. OWASP ZAP 2.15.0 can crawl and attack a running app, but authenticated flows require maintained scripts or it will test the wrong surface. Trivy v0.50 can scan containers and SBOMs, but a clean image scan does not prove the app handles authorization correctly.

The privacy trade-off is that scanning and monitoring platforms often receive source code, dependency graphs, container layers, endpoint names, stack traces, and sometimes sample payloads. That may be acceptable, but it should be a deliberate vendor-risk decision rather than an unnoticed side effect of adding a badge to a pull request. CycloneDX 1.5 SBOMs are valuable for inventory, yet they reveal technology choices and package versions that attackers also enjoy knowing if the files are published without access control.

CVSS 4.0 scoring helps triage known vulnerabilities, but it is not a product-risk oracle because exploitability depends on your architecture, exposure, privileges, and compensating controls. A CVSS 9.8 library issue in an unreachable parser may be less urgent than a CVSS 6.5 authorization bug that exposes another tenant’s data. That claim is easy to disagree with until you remember that users experience impact, not scorecards.

Set service-level expectations for vulnerability handling, but leave room for judgment. For example, a team might target 7 calendar days for internet-exposed critical fixes and 30 days for lower-severity dependency upgrades; those are operating targets to tune with staffing and release safety. The mistake is pretending a fixed SLA solves prioritization, because rushed patches can cause outages and ignored patches invite compromise.

Your first concrete step should be a data-leak walk-through, not a new scanner. Open one recent production request path and trace where secrets, identifiers, and personal fields travel: logs, traces, analytics, queues, error reports, tokens, caches, and backups. Delete one unnecessary field this week, then add a test or lint rule so it stays deleted.