Home
/
Blog
/
Technical

How to deobfuscate JavaScript, step by step

A working method for the case that actually matters: an unreadable third-party script on a page you are responsible for, and a decision to make about it today.
TL;DR
Deobfuscating a script
PRESERVE FIRST
The file at that URL can change, and some payloads serve clean code to a second request. Save the exact bytes and hash them before you touch anything.
STRINGS, THEN STRUCTURE
Recovering the string table and substituting the decoder calls does most of the work. Control-flow unflattening is easier afterwards.
RUNTIME ANSWERS IT FASTER
Static analysis tells you what the code is. Breakpoints on the network and form sinks tell you what it did, which is the question you actually have.
Short on time? You rarely need a full reverse. Five questions decide whether to escalate.

How do you deobfuscate JavaScript?

You deobfuscate JavaScript by recovering the string values the code hides, substituting the decoder calls back to those literals, unflattening the control flow, and then confirming the result by watching the script run in an isolated environment. Everything needed to understand the script is present in the file you already have, because the browser has to execute it. Obfuscation raises effort, never possibility. The method below is ordered for the question most people actually arrive with, which is not "what does every line do" but "is this reading my checkout form, and do I need to act on it today".
The exfiltration never touches your server, so a WAF never sees it. The request happens in the visitor's browser, which is the only place it can be observed.

Step 0: preserve the bytes before you touch anything

This step is skipped most often and is the one you cannot redo. The file served at a URL today is not necessarily the file that ran an hour ago. Attackers revert. Some payloads serve the malicious variant to a fraction of requests, or only when the Referer is a checkout page, or only outside the target's business hours.
So before any analysis, record:
The response body saved to disk, byte for byte, not a copy pasted out of a viewer.
Its SHA-256. This is what makes any later claim about the file checkable.
The full URL, the exact time with a timezone, and the page that loaded it.
The response headers, including Content-Type, ETag, Last-Modified and any CDN cache headers. An ETag that changes between two fetches minutes apart is itself a finding.
Pull the bytes from the browser that already loaded the script rather than fetching fresh with a command-line tool from a datacentre address with no Referer. A payload that gates on those conditions will hand you clean code and you will conclude, wrongly, that nothing is there.
Then work on a copy. Editing in the browser's developer tools on the live page is fine for looking, but it is not a record of anything.

Step 1: format it, and know that this is not deobfuscation

Run the file through a formatter so it has line breaks again. This changes nothing about how hard it is to read, but it makes everything after this step possible.
Two numbers are worth noting as you do it. A file that is four lines before formatting and twelve thousand after is packed or generated. A file that was two thousand lines and stays about two thousand is probably ordinary minified vendor code, and minified is not obfuscated: minification renames and compresses, it does not hide.
The fastest triage is to look at what strings survive. Legitimate minified bundles keep readable literals: URLs, error messages, property names, feature flags. A file with no human-readable strings at all has had them deliberately removed, and that is the signal to keep going.
Then search the formatted file for the constructs that assemble or execute code at runtime: eval, Function(, atob, fromCharCode, unescape, long \x or \u runs, and document.createElement('script'). Presence is not guilt, plenty of legitimate tags use some of these. Absence of all of them in a script you suspected is a useful negative.
Hashing and archiving every third-party payload is the part you should not be doing by hand.

Step 2: recover the string table and substitute it back

This is where most of the readability comes from, and the pattern is remarkably consistent because the obfuscators producing it are automated.
You will usually find three things near the top of the file: an array holding every string in the program, an immediately-invoked function that rotates that array until an arithmetic checksum over its contents matches a constant, and a small decoder function referenced everywhere else as something like _0x3a1b(0x1f, 'aBc').
The method:
Isolate those three pieces into a separate file, with nothing else.
Run that file alone in a sandbox with no network access, then call the decoder across the index range and print the mapping. You are executing attacker-controlled code, so it belongs in a throwaway container, not on your laptop's normal profile.
Substitute mechanically. Replace every decoder call in the source with the literal it returns. This is a syntax-tree transform rather than find and replace, because the arguments are expressions, not constants.
One failure mode is worth naming because it wastes hours. If the decoded values come back as nonsense, you almost certainly called the decoder before the rotation loop had settled the array. The rotation is order-dependent and must run to completion first.
A word on tooling: do not paste a script pulled off your own production checkout into an online deobfuscator. In a suspected incident that is evidence leaving your control, sent to a third party you have no agreement with. Everything above runs locally.

Step 3: unflatten the control flow

Control-flow flattening replaces the structure of a function with a dispatch loop. The body becomes a while loop around a switch, and the real order of operations is held in a string like '3|0|4|1|2'.split('|') that the loop walks.
This looks worse than it is. Read the order string, then read the cases in that order, and you have the original sequence. You do not need a tool for it.
Dead branches are usually guarded by opaque predicates, conditions that are always true or always false but not obviously so, such as a comparison between two encoded strings. Once step 2 has replaced those with literals the branches collapse on sight, which is the reason strings come first.

Step 4: watch it run, which is usually the faster answer

Static analysis tells you what the code is. Runtime observation tells you what it did, and the second is what an incident report and an assessor both need.
Breakpoint the sinks, not the source. You do not need to understand the code to see where data leaves. In a disposable browser profile, on a copy of the page, with no real card data in any field, set breakpoints on:
What to watch
Why it matters
XMLHttpRequest.prototype.send and fetch
The two ordinary ways data leaves. The destination is visible at the moment of the call however the URL was assembled
navigator.sendBeacon
Survives page unload, which is exactly when a skimmer wants to send
Setting .src on a new Image
Exfiltration by image request, the classic skimmer channel, and invisible in most network filters
WebSocket construction
A long-lived channel that does not appear as discrete requests
document.createElement('script')
Second-stage loading. The real payload is very often not in the file you are reading
Listeners on the form and its inputs
Shows whether the script reads fields, and the call stack tells you which script attached
Capture three things and you have the whole story: every outbound destination with its payload, every DOM node the script reads, and any further code it loads. A script that touches none of the three is almost certainly what it claims to be.

The anti-analysis tricks you will hit, and what to do

Trick
What you see
What to do
debugger in a loop
Developer tools freeze the moment you open them
Use the never-pause-here option on that breakpoint, or strip the statements from your local copy
Developer-tools detection
The script behaves differently while tools are open
Undock the tools into a separate window, or observe from a proxy instead of the browser
Hostname or Referer gate
Runs clean in your sandbox and nowhere else
Serve your copy from a host that matches, and set the Referer the real page would send
Timing checks
Dies or silently no-ops while you step through
Do not single-step. Use logging breakpoints that record and continue without pausing
One-in-N delivery
One fetch looks completely clean
Fetch repeatedly over time and compare hashes, rather than trusting a single sample
Automation checks
Nothing happens under a headless browser
Use a real browser profile, not a headless one
Every one of these is also a finding in its own right. A third-party script on a payment page that refuses to run while developer tools are open has no legitimate reason to care.

The five questions that actually decide it

You do not need to understand every line. You need to answer five things, and then you can make a decision:
Does it read the payment form? Any access to input values, or a listener on submit.
Where does it send data, and is that destination in your inventory? A domain nobody can account for is the finding, whether or not you understand the code that reached it.
Does it load more code, and from where? The load chain is where the real payload usually lives, and it is the part a one-off review misses.
Does it modify the form? Added fields, an overlay drawn on top, or a changed submit target.
Has the file changed, and when? Compare the hash you took in step 0 against whatever record you have from before.
Four of those five are questions about behaviour and change, not about code. That is the reason the standard asks for a record rather than a code review.

When to stop, and what to do next

Stop when the five questions are answered. If any of them answers badly, you are in incident response and the priorities change: preserve what you have, pull logs for the window between the earliest hash you can evidence and now, and follow your acquirer's notification path. Removing the script is correct, but do it knowing that an attacker watching their own callback will notice.
Then the uncomfortable part. You did this by hand because something was already suspicious, which means the detection came from somewhere else, or came late. PCI DSS 6.4.3 and 11.6.1 ask for exactly what you just produced, an inventory entry with a justification and an integrity record, and a change-detection record, except for every script on the page and on a schedule rather than for one script once.
For the conceptual background on why these files look the way they do, see JavaScript obfuscation and de-obfuscation. For where obfuscated code tends to come from in the first place, see shadow code and tag manager security.
Sources and methodology
The techniques described here are properties of widely used JavaScript obfuscators and of the browser APIs involved, both of which are publicly documented. No sample from any customer is described. PCI DSS requirement text is quoted from PCI DSS v4.0.1 (June 2024), published by the PCI Security Standards Council. Requirements 6.4.3 and 11.6.1 became mandatory on 31 March 2025. We publish a script monitoring product and that is a bias worth knowing about while you read.
ASK AN AI
Don’t just take our word for it
Ask your assistant what to look for in a tool for PCI DSS 6.4.3 and 11.6.1.

Frequently asked questions

Yes, in the ordinary case. Analysing code that was served to your own website, on your own systems, is routine defensive security work and is what incident response consists of. Two caveats worth knowing: a vendor contract may contain a reverse-engineering clause, which is a commercial question rather than a legal barrier to inspecting what runs on your page, and none of this is about circumventing access controls or licensing. If the script is unattributed, no contract applies.
You can for a public sample, and a good online JavaScript deobfuscator handles the common obfuscators well. Do not do it with a script pulled off your own production checkout during a suspected incident. That uploads evidence to a third party you have no agreement with, and it can put the sample somewhere the attacker can watch. Run the tooling locally instead.
No, and conflating them wastes a lot of time. Minification shortens names and removes whitespace to reduce file size, and it leaves readable string literals, URLs and error messages behind. Obfuscation deliberately removes meaning: encoded strings, array indirection, flattened control flow. Nearly every legitimate vendor tag is minified. Relatively few are obfuscated.
It is usually shorter than expected. A commonly obfuscated sample takes one to three hours to get to the point where the five questions are answered, and most of that is step 2. A sample with layered anti-analysis and a second stage that only loads under the right conditions can take a day or more, which is the point at which runtime observation earns its place over static work.
No, and trying to is the most common way this stalls. The decision needs five answers: does it read the form, where does it send, does it load more code, does it change the form, and has it changed. A complete line-by-line reverse is a research exercise, not a prerequisite for removing a script you cannot justify.
That happens, and it is a known evasion rather than a reason to close the ticket. Payloads gate on the Referer, the visitor's country, the user agent, a fraction of requests, or the time of day. Fetch several times from conditions that resemble a real visit, compare hashes across those fetches, and treat a file that differs between two fetches minutes apart as the finding itself.
No, not on the basis of obfuscation. A CSP is an origin allowlist: it decides where a script may load from, never what a script does once it is allowed. An obfuscated payload served from a vendor you already allow passes the policy cleanly, which is exactly the case that matters on a payment page. See Content Security Policy.
No, not automatically, but it is a reason to require a justification. Some legitimate vendors obfuscate anti-fraud logic on purpose, and that is a defensible answer if they give it. What is not defensible is a script on a payment page that nobody can attribute and that has been deliberately made unreadable. PCI DSS 6.4.3 asks for a written justification per script, and "we do not know what this is" does not satisfy it.
Less than you would think: a browser with developer tools, a code formatter, a JavaScript runtime you can run offline in a throwaway container, and optionally a syntax-tree tool for the substitution step. Everything in this post is done with those. The expensive part is never the tooling, it is having the original bytes and a previous hash to compare against.
No, not on its own, though it produces the right kind of material. What you have at the end is an authorisation-grade justification and an integrity record for one script at one moment. Both requirements are continuous: 6.4.3 covers every script on the payment page with a justification and an integrity method, and 11.6.1 requires change detection on the page and its HTTP headers at least every seven days. Doing this by hand for one script does not scale to either.

See every script on your own checkout.

Install the tag, mark your payment pages, and watch the inventory fill in. Free forever on 1,000 page views a month, no credit card.

Free forever plan. No credit card.