Security Best Practices for Modern Web Apps
What the 2025 web hacking research actually means for developers

Every year, the security research community votes on the most impactful web hacking techniques of the previous year. PortSwigger's Top 10 Web Hacking Techniques of 2025 was published in February 2026 — and if you read it as a developer rather than a pentester, it's essentially a roadmap of what attackers will be doing to production apps this year.
This post translates that research into concrete practices. Not generic advice — specific patterns that address the actual attack classes dominating the threat landscape right now.
The 2025 threat landscape at a glance
A few themes stand out from this year's top 10:
Side-channels went mainstream. Three of the top ten entries were XS-leak / cross-origin side-channel techniques — using timing, connection pool behaviour, and ETag length to leak data across origins without any injection. James Kettle's conclusion: "2025 saw the rise of side-channels as a core exploitation primitive."
Framework internals are a target. Two entries were specifically about Next.js vulnerabilities — internal cache poisoning and race conditions in response caching. Popular frameworks are high-value targets precisely because they're everywhere.
Parser differentials keep winning. Number one overall was error-based SSTI; number four was Unicode normalization exploitation. Mismatches between how different components parse the same input remain one of the most reliable attack vectors.
Prompt injection hit CI/CD. For the first time, AI-specific attacks made the nominations list — prompt injection through GitHub Actions targeting AI agents with access to secrets and repository operations.
Here's what each of those trends means for how you build.
1. Input validation must happen at every layer
The #1 technique of 2025 — blind SSTI via error-based extraction — works because developers assume that if input doesn't render visibly, it isn't being evaluated. It is.
The #4 technique — Unicode normalization exploitation — works because validation happens before normalization, so the sanitized version of a string is different from what gets processed downstream.
The practical rule: validate after normalization, not before.
import unicodedata
def sanitize_input(user_input: str) -> str:
# Normalize FIRST, then validate
normalized = unicodedata.normalize('NFKC', user_input)
# Now validate the normalized form
if len(normalized) > 500:
raise ValueError("Input too long")
# Strip control characters
cleaned = ''.join(c for c in normalized if not unicodedata.category(c).startswith('C'))
return cleaned
For template engines specifically: never pass user input into template strings. Treat templates as code, because that's exactly what they are to an evaluator.
# Never do this
template_str = f"Hello {user_input}!" # SSTI if user_input = "{{7*7}}"
rendered = template.render(template_str)
# Do this — user input goes into context, never into the template itself
rendered = template.render("Hello {{ name }}!", name=user_input)
2. Cache architecture needs explicit security boundaries
The #7 technique was a critical Next.js vulnerability: chaining a spoofable internal header with data-request mechanisms to force-cache server-rendered JSON as HTML, enabling stored XSS via stale-while-revalidate. This wasn't a framework bug you could ignore — it affected the internal caching layer of one of the most widely deployed frameworks in production.
Key principles for cache security:
Never cache responses that contain user-controlled data without explicit validation. Even if the response looks safe, a downstream cache can serve it to a different user in a different context.
Strip internal/forwarded headers at your edge. If your application behaves differently based on headers like x-forwarded-host, x-middleware-rewrite, or custom internal headers, those must be stripped by your load balancer or CDN before reaching the application.
# nginx — strip internal headers before proxying
proxy_set_header X-Forwarded-Host "";
proxy_set_header X-Middleware-Rewrite "";
proxy_set_header X-Internal-Route "";
Separate cache keys for authenticated vs unauthenticated content. Content that varies by user must vary the cache key accordingly. A CDN that serves the same cached response to authenticated and unauthenticated users is a data leak waiting to happen.
3. Treat cross-origin information leakage as a real threat
Three top-10 entries were XS-leak techniques — ways to infer information about cross-origin responses without any injection. The Chrome connection-pool oracle (#8), the ETag length leak (#6), and the Next.js cache race condition (#7) all demonstrate that browser and infrastructure behaviours can be chained to leak data that was never meant to be readable.
For developers, the defensive surface here is:
Implement proper CORS policies. Wildcard Access-Control-Allow-Origin: * on any endpoint that returns user-specific data is a serious misconfiguration.
# FastAPI — explicit CORS, not wildcard
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://craftripple.com"], # explicit, not *
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)
Set Cross-Origin-Resource-Policy and Cross-Origin-Opener-Policy headers. These headers limit what cross-origin pages can do with your resources and significantly reduce the XS-leak surface.
# Add security headers to every response
@app.middleware("http")
async def add_security_headers(request, call_next):
response = await call_next(request)
response.headers["Cross-Origin-Resource-Policy"] = "same-origin"
response.headers["Cross-Origin-Opener-Policy"] = "same-origin"
response.headers["Cross-Origin-Embedder-Policy"] = "require-corp"
return response
4. ORM filtering is not the same as access control
The #2 technique — ORM leaking more than you joined for — showed how search and filter endpoints that use ORM query builders can be exploited to enumerate data the user shouldn't have access to. The attack abuses filter expression overloads to access fields beyond what the application intended to expose.
This is a class of bug that's widespread in SaaS applications that expose dynamic filtering (think ?filter[status]=active&filter[user_id]=123).
Never expose raw ORM filter parameters to user input. Define an explicit allowlist of filterable fields.
# Dangerous — passes user input directly to ORM
def get_projects(filters: dict):
return Project.objects.filter(**filters) # user can filter ANY field
# Safe — explicit allowlist
ALLOWED_FILTERS = {"status", "created_after", "project_type"}
def get_projects(filters: dict, user: User):
safe_filters = {k: v for k, v in filters.items() if k in ALLOWED_FILTERS}
# Always scope to the current user — never rely on filters alone for access control
return Project.objects.filter(owner=user, **safe_filters)
Access control must be enforced at the query level, not just at the route level. A user authenticated to /projects should only ever see their own projects regardless of what filter parameters they pass.
5. Security headers: the baseline in 2026
With XS-leaks, clickjacking, and CSP bypasses all featuring in the 2025 research, security headers are no longer optional. Here's the minimum viable set for any production web app:
SECURITY_HEADERS = {
# Prevent clickjacking
"X-Frame-Options": "DENY",
# Stop MIME-type sniffing
"X-Content-Type-Options": "nosniff",
# Force HTTPS
"Strict-Transport-Security": "max-age=31536000; includeSubDomains; preload",
# Control what can be embedded cross-origin
"Cross-Origin-Resource-Policy": "same-origin",
"Cross-Origin-Opener-Policy": "same-origin",
# Referrer policy
"Referrer-Policy": "strict-origin-when-cross-origin",
# Content Security Policy — define your own based on what your app uses
"Content-Security-Policy": (
"default-src 'self'; "
"script-src 'self'; "
"style-src 'self' 'unsafe-inline'; "
"img-src 'self' data: https:; "
"connect-src 'self' https://api.yourdomain.com; "
"frame-ancestors 'none';"
),
# Permissions policy — disable what you don't use
"Permissions-Policy": "camera=(), microphone=(), geolocation=()",
}
One note on CSP: unsafe-inline for scripts is effectively no CSP for XSS. If you need inline scripts, use nonces — and be aware of the nonce-reuse-via-disk-cache technique that also made the 2025 nominations list. Nonces must be unique per request, never static.
6. Prompt injection is a supply chain risk now
The nominations list included a technique for injecting malicious instructions through GitHub Issues and PR descriptions into AI agents running in CI/CD pipelines. If your CI uses an AI agent with access to secrets or can push to production, that agent is a potential attack surface.
Treat AI agents in your pipeline the same way you treat third-party dependencies: minimal permissions, explicit scope, and audit logging of everything they do.
# GitHub Actions — scope permissions explicitly, don't use defaults
permissions:
contents: read # not write unless needed
pull-requests: read # not write unless needed
actions: none
# Never pass untrusted content (issue bodies, PR descriptions) directly into
# AI agent prompts without sanitization
7. Automated scanning as a development habit
Running a vulnerability scanner once before launch isn't a security practice — it's a checkbox. The attack surface of a web app changes with every deployment.
I run Nuclei against my projects as part of CI using the community template library. It catches misconfigurations, exposed headers, and known CVEs automatically on every build.
# .github/workflows/security-scan.yml
- name: Run Nuclei scan
uses: projectdiscovery/nuclei-action@main
with:
target: https://staging.yourapp.com
flags: "-severity medium,high,critical -t exposures/ -t misconfiguration/"
This catches the low-hanging fruit automatically so manual review can focus on logic flaws and application-specific vulnerabilities that templates can't find.
The mindset shift
The 2025 research confirms something that security practitioners have known for a while: the easy vulnerabilities (SQL injection, reflected XSS, basic CSRF) are largely solved by frameworks and libraries. What's left — and what's winning top 10s — is more subtle: parser differentials, side-channel leaks, cache behaviour exploitation, and logic flaws in how components interact.
That shift means security can't be bolted on after the fact. The decisions that determine whether your app is vulnerable to cache poisoning or ORM leakage are made during architecture and code review — not during a pentest six months later.
Build it in from the start. It's far cheaper than fixing it later.
Liban Abdullahi is the founder of CraftRipple Studio, a Brussels-based development studio specialising in secure full-stack web applications.
Work with CraftRipple Studio if you need security built into your application from day one.