devtools-detection
v0.1.1
Published
Detect when browser devtools are open, with confidence-weighted detectors and no false-positive noise.
Downloads
49
Maintainers
Readme
devtools-detection
Detect when browser devtools are open, and react however you like.
- Three detectors, weighted by trust. The noisy one corroborates; it never triggers on its own.
- Sees undocked devtools. A worker-based
debuggerprobe catches what window-size heuristics miss. - Nothing happens on import. No timers, no globals, no browser APIs touched until you call
start(). Safe in a Next or Nuxt server bundle. - ~1.4 KB gzipped in your bundle, with no dependencies. npm's 30.8 KB "unpacked" figure is mostly type declarations, the standalone CDN build, and this README — none of which reach a browser.
npm install devtools-detectionQuick start
import { createDetector } from 'devtools-detection'
const detector = createDetector({
onOpen: (state) => console.warn('devtools opened by', state.detectedBy),
onClose: () => console.warn('devtools closed'),
})
detector.start()onOpen and onClose fire on the transition, not on every poll. Call detector.stop() when you're done — it clears the timer and terminates the worker.
How detection works
Every technique here is a heuristic, and they fail in different places — which is exactly why the library runs several and weighs them differently.
| Detector | Default | Confidence | Sees undocked? | How it works | Where it fails |
| --- | --- | --- | --- | --- | --- |
| debugger | on | high | yes | Pings a worker that runs into a debugger statement. No reply means the breakpoint caught it. | Firefox only pauses workers while its Debugger panel is active. Defeated by deactivating breakpoints. |
| size | on | low | no | Compares outerWidth/innerWidth. Docked devtools eats the difference. | Blind to undocked devtools. Sidebars, bookmark bars and zoom all trigger it. |
| console | off | high | yes | Logs a value whose toString records that it ran. Browsers only format a logged value when a console is there to render it. | Fires for any attached devtools-protocol client, Playwright and Puppeteer included. Defeated by overriding console.log. Writes to the console. |
A detection counts as open once a high-confidence detector fires. size firing alone is reported but does not flip isOpen, which is where most of the false-positive noise in this category comes from.
detector.getState()
// { isOpen: true, confidence: 'high', detectedBy: ['debugger', 'size'], orientation: 'vertical' }detectedBy lists everything that fired, including detectors below the threshold — so you can see a size-only signal without acting on it.
Why console is off by default
What it really detects is a console consuming output, and an automation client attached over the devtools protocol looks identical to an open devtools window. Left on, it reports devtools as open during every Playwright or Puppeteer run and silently trips your onOpen handler mid-test. Turn it on when that broader reading is what you want:
createDetector({ detectors: ['debugger', 'console', 'size'] })Examples
Redirect to a blocked page
import { createDetector } from 'devtools-detection'
import { redirect } from 'devtools-detection/reactions'
createDetector({ onOpen: redirect('/blocked') }).start()redirect() uses location.replace(), so it overwrites the current history entry instead of pushing a new one — the back button won't bounce them straight back.
Two things to get right here:
The blocked page must not run detection itself. If /blocked also detects devtools and redirects, it redirects to itself forever. Serve it as a plain page with no detector on it.
Give yourself a bypass, or you lock yourself out. Once armed, arriving with devtools still open fires the redirect again immediately, so you can never get back in to debug:
const bypassed = new URLSearchParams(location.search).get('token') === 'let-me-in'
createDetector({
enabled: !bypassed,
onOpen: redirect('/blocked'),
}).start()Use a signed or hashed token in production rather than a guessable string — a plain flag is trivially discoverable by anyone reading your bundle.
Blank the page instead
import { blankPage } from 'devtools-detection/reactions'
createDetector({ onOpen: blankPage({ message: 'Nothing to see here.' }) }).start()Send it to analytics
onOpen is just a callback, so anything works — this is the least invasive use and the one most likely to survive contact with real users:
createDetector({
interval: 5000,
onOpen: (state) => {
navigator.sendBeacon('/api/events', JSON.stringify({
type: 'devtools_opened',
detectedBy: state.detectedBy,
confidence: state.confidence,
}))
},
}).start()React
subscribe and getState are shaped for useSyncExternalStore, so there is no wrapper package to install:
import { createDetector } from 'devtools-detection'
import { useEffect, useSyncExternalStore } from 'react'
// Created once, outside the component — creating it in render would
// build a new detector on every pass.
const detector = createDetector()
function useDevtools() {
useEffect(() => {
detector.start()
return () => detector.stop()
}, [])
return useSyncExternalStore(detector.subscribe, detector.getState)
}
export function DevtoolsBadge() {
const { isOpen, detectedBy } = useDevtools()
if (!isOpen) return null
return <aside>devtools detected via {detectedBy.join(', ')}</aside>
}Listening from elsewhere in the app
If a callback is awkward to thread through, have it dispatch a DOM event instead:
createDetector({ emitEvent: true }).start()
window.addEventListener('devtoolschange', (event) => {
console.log(event.detail) // the same DetectionState object
})Tuning individual detectors
Pass instances rather than names when you want to configure one:
import { createDetector, debuggerDetector, sizeDetector } from 'devtools-detection'
createDetector({
detectors: [
debuggerDetector({ timeout: 300 }), // more patient on a slow device
sizeDetector({ threshold: 220 }), // fewer false positives from sidebars
],
}).start()Only the cheap check
Dropping debugger is worth doing when you would rather never pause execution — it is the one detector that stops on a breakpoint, which is hostile to anyone genuinely debugging your site:
import { createDetector, sizeDetector } from 'devtools-detection'
createDetector({
detectors: [sizeDetector()],
minConfidence: 'low', // required, since `size` is low-confidence on its own
onOpen: (state) => console.log('docked', state.orientation),
}).start()Do this for the behaviour, not the bundle. The detectors are a few hundred bytes between them, so trimming the list barely moves the total — you lose undocked coverage and gain false positives from sidebars and zoom.
Without a build step
<script src="https://unpkg.com/devtools-detection"></script>
<script>
DevtoolsDetection.createDetector({ onOpen: () => console.warn('hi') }).start()
</script>API
createDetector(options?)
| Option | Type | Default | Notes |
| --- | --- | --- | --- |
| detectors | Array<DetectorName \| Detector> | ['debugger', 'size'] | Names, or instances from the factories below. |
| interval | number | 1000 | Milliseconds between polls. |
| minConfidence | 'low' \| 'high' | 'high' | Confidence a detection must reach to count as open. |
| enabled | boolean | true | When false, start() does nothing. |
| onOpen | (state) => void | — | Fires on the transition, not on every poll. |
| onClose | (state) => void | — | Same. |
| emitEvent | boolean | false | Also dispatch a devtoolschange CustomEvent on window. |
Returns:
| Member | Type | Notes |
| --- | --- | --- |
| start() | () => void | No-op if already running or enabled is false. |
| stop() | () => void | Clears the timer, terminates the worker, resets state. |
| getState() | () => DetectionState | Reference is stable until the state changes. |
| subscribe(listener) | (() => void) => () => void | Returns an unsubscribe function. |
| isRunning | boolean | Read-only. |
DetectionState
| Field | Type | Notes |
| --- | --- | --- |
| isOpen | boolean | Whether detection met minConfidence. |
| confidence | 'low' \| 'high' | Best confidence among detectors that fired. |
| detectedBy | readonly string[] | Everything that fired, threshold or not. |
| orientation | 'vertical' \| 'horizontal' \| undefined | Only when a detector that tracks it fired. |
Detector factories
sizeDetector({ threshold = 170 })— pixel gap that counts as devtools.consoleDetector({ clear = true })— whether to clear the console after each probe. Turning it off keeps your own logs at the cost of one blank line per poll.debuggerDetector({ timeout = 150 })— how long to wait for the worker's reply. Raise it if a busy main thread causes false positives.
Development
The debugger detector pauses on breakpoints — including yours. Give yourself an escape hatch, or developing against your own site gets tedious fast:
createDetector({ enabled: import.meta.env.PROD })Or drop 'debugger' from detectors locally.
What this cannot do
Worth being straight about, because the alternative is an issue tracker full of surprises:
- Anyone determined will get past it. Overriding
console.log, deactivating breakpoints, or just editing the bundle all defeat detection. Treat this as a signal, not a lock. - It is not bot detection. The default detectors do not fire for Puppeteer or Playwright, which drive the browser over the devtools protocol without opening a devtools window. The opt-in
consoledetector does fire for them, but as a side effect rather than a designed check — do not lean on it for that. - Mobile browsers have no devtools. In-page consoles like eruda and vConsole are not detected.
- A strict Content Security Policy disables the
debuggerdetector. Creating a worker from ablob:URL needsworker-src blob:. Without it the detector reports closed rather than throwing, and the others carry on. - Firefox is weaker than Chrome. The worker only pauses while the Debugger panel is active, which is why
sizestays in the default set as a backstop.
Good fits: analytics, easter eggs, nudging casual copying. Bad fit: anything you would call DRM.
License
MIT © Marko Reljic
