Individual web performance and security best practices are widely documented, but rarely are explained together as a coherent system: TLS everywhere, session cookies configured correctly, no JWT in localStorage, aggressive caching only where it is safe to do so, Real revocation of sessions and WebSockets, rate limiting on real-time channels and monitoring connecting all these signals. This guide covers ten practices that, applied together, truly reduce the attack surface without sacrificing the performance perceived by users.
Each section includes concrete configurations (Nginx, Express, NestJS, WebSocket) and the real trade-offs of every choice — because every practice has a cost, and applying them without understanding it leads to configurations cargo-cult that don't really protect anything.
1. HTTPS / WSS Required
TLS is non-negotiable for either HTTP or WebSocket: without wss://, a plaintext WebSocket
exposes session tokens and application payloads to anyone on the same network. Configure TLS 1.2 as
minimum (1.3 where possible), HSTS with preload, and OCSP stapling to avoid latency
a real-time certificate verification at each handshake.
# nginx.conf — TLS 1.2/1.3, HSTS, OCSP stapling
server {
listen 443 ssl;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_stapling on;
ssl_stapling_verify on;
add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
location /ws/ {
proxy_pass http://backend_ws;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
2. Session Cookies: HttpOnly, Secure, SameSite
HttpOnly prevents JavaScript from reading the cookie (mitigates theft via XSS),
Secure only sends it over HTTPS, SameSite controls cross-site sending:
Strict for maximum CSRF protection (but breaks flows with external redirects),
Lax as a reasonable compromise for most applications,
None only if the cookie must be truly cross-site — and in that case it requires
Secure mandatory.
// Express/NestJS — cookie di sessione configurato correttamente
res.cookie('session_id', sessionToken, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 15 * 60 * 1000, // 15 minuti, coerente con la strategia di refresh
});
3. Never JWT in localStorage
localStorage is readable by any script executed on the page: a single XSS (even
in a third-party dependency, not in your code) exfiltrates the token without needing any interaction
user. The secure alternative is the HttpOnly cookie for the token itself, combined with
the double submit cookie pattern for CSRF protection when you also need a
stateless mechanism.
// Refresh token in HttpOnly cookie, access token short-lived in memoria (mai in storage persistente)
app.post('/auth/refresh', (req, res) => {
const refreshToken = req.cookies.refresh_token; // HttpOnly, non leggibile da JS
const newAccessToken = issueAccessToken(verifyRefreshToken(refreshToken));
res.json({ accessToken: newAccessToken }); // vive solo in memoria lato client, mai in localStorage
});
4. Static Asset Cache with Fingerprinting
An asset with hash in the filename (main.a1b2c3d4.js) can be cached for an entire year
with immutable, because every change to the content automatically generates a file name
different — invalidating the cache never requires a manual purge.
# Angular CLI genera automaticamente asset con hash nel filename in build di produzione
ng build --configuration production
# Output: main.a1b2c3d4.js, styles.e5f6a7b8.css
# Header cache per asset fingerprinted
location ~* \.[0-9a-f]{8}\.(js|css)$ {
add_header Cache-Control "public, max-age=31536000, immutable";
}
5. Don't Cache Sensitive Responses
Any API response with personal or user session data must explicitly state
Cache-Control: no-store (never saved, not even in temporary memory) or private
(caching only from the user's browser, never from a shared cache/CDN). If the answer varies in
based on a header like Authorization or Accept-Language, declare it with
Vary to prevent an intermediate cache from serving the wrong user response.
// NestJS — risposta con dati personali, mai cacheabile
@Get('me')
getProfile(@Res({ passthrough: true }) res: Response) {
res.setHeader('Cache-Control', 'no-store');
return this.userService.getCurrentProfile();
}
6. CDN with s-maxage Separate from Browser
s-maxage controls how long the CDN (shared cache) keeps the response,
regardless of max-age controlling the user's browser — this allows
serve quasi-static content from the edge for minutes while keeping your browser up to date in minutes
seconds, or vice versa.
# Header: CDN cachea 5 minuti, browser solo 30 secondi
Cache-Control: public, s-maxage=300, max-age=30
# Purge mirata di un singolo path sulla CDN dopo un deploy
curl -X POST "https://api.cdn-provider.com/purge" -H "Authorization: Bearer $CDN_TOKEN" -d '{"path":"/api/products"}'
7. Refresh and Revoke Tokens for Sessions and WebSockets
A short-lived access token (10-15 minutes) plus a longer-lived HttpOnly refresh token limits the damage window in case of theft. Real revocation requires server-side state (a blacklist or per-user version counter): a purely stateless JWT cannot be invalidated before its natural expiry. For WebSockets, the revocation must actively close the connection, don't just reject subsequent HTTP requests.
// Revoca sessione: invalida il refresh token e chiude ogni WebSocket associato all'utente
async function revokeSession(userId: string) {
await sessionStore.incrementTokenVersion(userId); // invalida tutti i JWT emessi prima di ora
for (const socket of wsConnections.getByUserId(userId)) {
socket.close(4001, 'session_revoked');
}
}
8. Rate Limiting and Size Limit on WebSocket
Without limits, a single client can saturate the server with high rate messages or payloads huge — is the simplest form of application DoS to execute against a non-WebSocket channel protected.
// ws — rate limiting e size limit per connessione
const wss = new WebSocketServer({ maxPayload: 64 * 1024 }); // 64KB max per messaggio
wss.on('connection', (socket) => {
let messageCount = 0;
const resetInterval = setInterval(() => (messageCount = 0), 1000);
socket.on('message', (data) => {
if (++messageCount > 20) return socket.close(4008, 'rate_limit_exceeded'); // max 20 msg/sec
handleMessage(data);
});
socket.on('close', () => clearInterval(resetInterval));
});
9. Monitoring
The metrics to track cover four areas: connections (active WebSockets, rate reconnection), latency/throughput (API response time, messages/second), cache (hit/miss ratio for the CDN, effective TTL observed) and auth (refresh token failure rate, sessions revoked). Prometheus + Grafana cover metrics well real-time numeric and dashboards; ELK (or an equivalent) remains best suited for application logging structured and ad hoc research on security events.
// Prometheus — contatore custom per connessioni WebSocket attive
const wsConnectionsGauge = new client.Gauge({ name: 'ws_active_connections', help: 'Connessioni WebSocket attive' });
wss.on('connection', () => {
wsConnectionsGauge.inc();
return () => wsConnectionsGauge.dec();
});
10. Accessibility and Privacy Test
Verify that no cookies or cached headers inadvertently expose personal data (a
Set-Cookie with plaintext email in the name, a publicly cached response that
contains the username). Automated tests (axe-core for a11y, IC security header scanner)
they cover objective cases; a periodic manual review of the Cache-Control headers on
routes with personal data remains necessary because a new endpoint can easily forget
the correct header.
Quick Operational Checklist
- High priority: HTTPS/WSS everywhere, HttpOnly+Secure+SameSite session cookie, remove any JWT from localStorage.
- Medium priority: static asset fingerprinting,
no-storeheader on responses with personal data, rate limiting WebSocket. - Continuous priority: connection/cache/auth monitoring, periodic review of cache headers on new routes.
30/60/90 Day Plan
Days 1-30
- HTTPS/WSS forced everywhere, HSTS active — KPI: 0 endpoints reachable in plaintext.
- Session cookies migrated to HttpOnly+Secure+SameSite — KPI: 0 tokens read from client-side JavaScript.
Days 31-60
- Rate limiting active on all WebSocket channels — KPI: 0 connections capable of exceeding the set limits.
- Fixed caching headers on all routes with personal data — KPI: Full audit, 0 publicly cacheable sensitive responses.
Days 61-90
- Complete monitoring dashboard (connections, cache, auth) — KPI: alerts configured on each critical metric.
- Token and WebSocket revocation tested end-to-end — KPI: effective revocation time under 2 seconds.
FAQ
SameSite=Strict or Lax for a session cookie?
Lax is the correct compromise for most applications; Strict only if you do not have flows with redirects from external domains to the app.
Why not just use sessionStorage instead of localStorage for the JWT?
Both are equally readable by JavaScript and therefore vulnerable to XSS: the difference is only durability, not security.
does-maxage work without a CDN configured?
No, it is ignored by browsers: it only affects compliant shared caches such as CDNs or reverse proxies that explicitly support it.
How do I revoke a JWT before it expires?
With server-side state (blacklist or per-user token version), because a pure stateless JWT cannot be invalidated before natural expiration.
What is a reasonable limit of messages per second for a WebSocket?
Depends on the use case, but 10-30 messages/second per connection is a reasonable starting point for most real-time applications.
Is OCSP stapling mandatory?
Not mandatory but strongly recommended: reduces TLS handshake latency by preventing the client from contacting the certification authority separately.
How do you prevent a CDN from serving a sensitive response to the wrong customer?
With Cache-Control: private or no-store on personal responses, never public when data varies per user.
Do you also need to monitor token refresh failures?
Yes, a sudden increase in refresh failures is often the first sign of an ongoing attack or token expiration bug.
Common Mistakes to Avoid
- Cleartext WebSocket (
ws://) behind an HTTPS frontend: Browser only allows this if the WebSocket is on the same insecure origin, but still exposes plaintext data to the network. - Cookies without
Securein development also left in production: often copied from a test configuration that was never updated. - JWT in localStorage "temporarily" during development: almost always becomes permanent because it "works" and no one sees it again.
- Cache-Control absent by default on new API routes: without an explicit header, caching behavior is client-dependent and not guaranteed.
- s-maxage and max-age identical: eliminates the advantage of being able to invalidate the browser cache more quickly than the edge cache.
- No maximum size on WebSocket messages: A single client can send huge payloads and saturate server memory.
- Revoke that only blocks future HTTP requests: Leave existing WebSocket connections alive with the same compromised token.
- No monitoring of cache hit/miss: a decreasing hit ratio often signals a regression in cache headers that has gone unnoticed.
Quick Replies
Why is HTTPS also required for WebSockets? A plaintext WebSocket (ws://) exposes session tokens and application payloads to anyone on the same network; wss:// encrypts the entire connection exactly as HTTPS does for traditional HTTP requests.
What is the SameSite flag of a cookie? Controls whether a cookie is sent in cross-site requests: Strict blocks cross-site sending, Lax allows it only for top-level navigation, None always allows but requires Secure.
Why not save a JWT to localStorage? Because it is readable by any JavaScript script running on the page: a single XSS, even in a third-party library, allows the token to be exfiltrated without any user interaction.
What does Cache-Control: immutable mean? Tells the browser that the content of that URL will never change as long as the URL remains the same, avoiding even a conditional validation request during the cache period.
How do you revoke a session with active WebSockets? By invalidating the server-side state of the token (blacklist or version) and actively closing every WebSocket connection associated with that user, not just rejecting new HTTP requests.
What is the difference between max-age and s-maxage? max-age checks the user's browser cache, s-maxage checks shared caches such as CDN and reverse proxy, and when present takes precedence over max-age for those caches.
How to Check
- Check TLS and HTTPS redirects:
curl -I https://your-domain.comand check the headerStrict-Transport-Security. - Inspect TLS certificate and protocol:
openssl s_client -connect your-domain.com:443 -tls1_2. - Check cache headers on assets:
curl -I https://your-domain.com/main.a1b2c3d4.js, checkimmutableandmax-age. - Check that sensitive responses are not cacheable:
curl -I https://your-domain.com/api/me, checkno-store. - Simulate load on WebSocket to validate rate limiting:
npx artillery quick --count 100 -n 20 wss://your-domain.com/ws. - Test end-to-end revocation: Revoke a session via API and verify that the associated WebSocket closes within a few seconds.
Conclusion
None of these ten practices are sufficient on their own: TLS without properly configured cookies still leaves the session vulnerable, aggressive caching without distinguishing public responses from private exposes personal data, and rate limiting without monitoring does not allow you to know if it is really working. Applied together, with a prioritized adoption plan (HTTPS and cookies first, caching and rate limiting then, continuous monitoring always), form a basis of security and performance consistent for any modern web application with real-time components.
Do you want a printable checklist or assessment of your security setup application? Request a technical audit: in a few hours of analysis it is possible to identify the priority gaps on cookies, caching, WebSockets and monitoring.