September 4, 2026
Inside the Script Block: JavaScript String, Template Literal, and JSON Breakouts
Hello, I am Nitin.

By Nitin yadav
7 min read
Day four. Today we go inside the inline script, which is where I find a disproportionate number of the XSS that survives on mature, well-tested programs.
Here is why this context is so productive. Most sanitisation on a modern app is HTML-aware. The templating engine escapes angle brackets, ampersands, and quotes on the way into the document body, and that handles the obvious cases. But when a developer needs to pass a value into an inline script โ a search term, a user ID, a locale, a feature flag, a whole hydration blob โ they frequently reach for a different code path. String concatenation. A custom serialiser. A JSON stringify that was never configured to escape for HTML.
That different code path is where the bugs are.
And critically: HTML encoding does not help you here. If the app encodes an angle bracket to its entity form inside a script block, that is not defence, that is a syntax error, because the JavaScript parser does not decode HTML entities inside a script element. So developers often turn that encoding off for script contexts and forget to replace it with anything.
Context 1: the JavaScript string
The most common shape is a variable assignment where your input sits inside a double-quoted string literal. Angle brackets are irrelevant. You need a quote to escape the string.
The three payload forms, and when to use each
Statement termination:
"; alert(document.domain); //"; alert(document.domain); //Clean and readable. The trailing comment swallows the leftover garbage.
Expression concatenation:
"-alert(document.domain)-""-alert(document.domain)-"This is my default and it is better than the first form for a specific reason: it never breaks the statement. The result is still a valid assignment, the rest of the script keeps running, and the page keeps working. If the page has a global error handler, a broken script can prevent your payload from ever executing, and it definitely makes your screenshot less convincing.
Function body escape:
"};alert(document.domain);function x(){""};alert(document.domain);function x(){"Useful when your reflection is inside a function body and simple termination leaves unbalanced braces.
When the quote is escaped
Test the quote and look at what comes back. Common outcomes:
Backslash-escaped. The app is escaping quotes. Now test whether the backslash is escaped. Send a lone backslash. If it comes back as a single backslash, you win:
\"-alert(document.domain)//\"-alert(document.domain)//Your backslash escapes their backslash, leaving your quote free to terminate the string. This is one of the most reliable JS-context bypasses that exists, and it turns up constantly in hand-rolled escaping.
HTML-encoded. Inside a script block this is broken markup, not defence. The JavaScript parser sees the literal entity characters. Your quote never materialises, so this specific route is closed โ but the closing-tag route below is unaffected.
Stripped entirely. Move to the alternatives.
The closing-tag route
This is the one people forget. Even though you are inside JavaScript, the HTML parser is still running, and the script element is a raw text element. The HTML tokeniser is scanning for the literal closing-tag sequence. It does not know or care about JavaScript syntax, string literals, or comments.
So if the angle bracket and slash survive, you close the script element and inject fresh markup after it. This works even if you are inside a string, inside a comment, inside anything. The string does not protect the page, because the string does not exist yet โ the HTML parser has to finish tokenising before the JS parser ever runs.
Test this on every script-context reflection, independently of quote testing. They fail independently, and I have found plenty of bugs where quotes were bulletproof and the closing tag walked straight through.
A related quirk: the tokeniser is looking for the closing-tag name followed by a whitespace character, a slash, or the closing angle bracket. So malformed variants with a trailing slash or extra space also close the element, which occasionally slips past filters that string-match on the exact tag.
Context 2: the template literal
Template literals are now everywhere in modern JavaScript, and they are a genuinely distinct context with a genuinely distinct bypass.
You do not need to break out of the backtick. Template literals evaluate expressions inline:
${alert(document.domain)}${alert(document.domain)}That is the whole payload. No quotes. No angle brackets. No parentheses required if you go further and use tagged template syntax instead.
Why this matters so much: almost every filter, WAF rule, and hand-rolled escaper was written against quotes and angle brackets. Very few consider the dollar sign and brace. I test ${7*7} on every script-context reflection I find, purely because it costs one request and the hit rate is better than it has any right to be. If 49 comes back, you have execution.
Also test the backtick itself. If you can inject one you may be able to terminate the literal and use standard statement termination. And remember template literals nest: if your input is already inside an expression slot, you are in a plain JavaScript expression context and can write JavaScript directly.
Context 3: the JSON hydration blob
Every modern server-rendered framework serialises state to JSON and drops it into an inline script for the client to hydrate from. Next.js writes one global, Nuxt another, Redux apps another. Django and Rails apps roll their own. Whatever the name, the shape is the same.
There are two independent attack routes here and they fail independently, so test both.
Route A: break the JSON string. If the serialiser is not escaping quotes properly, you are back in the JS string case from context one.
Route B: break the script element. A correctly configured JSON serialiser escapes the opening angle bracket to its unicode escape form precisely to prevent this. Many do not, because the standard stringify does not do it by default โ you have to explicitly post-process the output or use a library that handles it.
Route B is the one worth checking first, because it works regardless of how well quotes are handled.
How to find hydration blobs fast:
curl -s <https://target.com/page> | grep -oE '__[A-Z_]+__|window\.[A-Za-z_]+\s*=\s*\{' | sort -ucurl -s <https://target.com/page> | grep -oE '__[A-Z_]+__|window\.[A-Za-z_]+\s*=\s*\{' | sort -uThen trace which fields in that blob you control. Profile name, display name, last search, referral code, current URL, locale, and any error message from a previous action are the usual suspects. Anything that came from a request parameter or from your own account settings.
This is also worth doing on the authenticated side of the app, where hydration blobs are much richer and much less tested. A stored value in your own profile that lands in the hydration blob on a shared page โ a team page, a public profile, a comment thread โ is a stored XSS with an unusually good chance of being undiscovered.
Context 4: inline event handler attributes containing script
Covered partly on day three, but it belongs here too because the nesting is the point. Two parsers run in sequence. The HTML parser reads the attribute value and decodes HTML entities. Then the JavaScript engine parses the result.
So a numeric entity for a quote becomes a real quote before JavaScript sees it:
'-alert(document.domain)-''-alert(document.domain)-'A filter checking for a literal quote in the input never sees one. Test entity forms on every event-handler reflection, decimal and hex, for both quote characters.
Context 5: JavaScript that builds more JavaScript
Look for these patterns in the code around your reflection, because they change everything:
eval(userControlled)
new Function(userControlled)
setTimeout(userControlled, 0) // string argument form
setInterval(userControlled, 0)
document.write(userControlled)
element.setAttribute('onclick', userControlled)eval(userControlled)
new Function(userControlled)
setTimeout(userControlled, 0) // string argument form
setInterval(userControlled, 0)
document.write(userControlled)
element.setAttribute('onclick', userControlled)If your input reaches any of these as a string, you have direct code execution and none of the quote-breaking above is necessary. The timer functions with a string first argument are the sleeper hits โ a lot of developers do not realise those are eval with extra steps.
This overlaps into DOM XSS territory, which is week two, but the sink list is worth internalising now.
A note on frameworks
People will tell you React makes this impossible. It does not โ it makes the HTML body context safe by default, which is a different claim.
React escapes values you interpolate into JSX. It does not help you when:
- The developer used the dangerous inner-HTML escape hatch
- The value goes into an href and the app is on an older React that did not block the javascript scheme
- The value is written into the server-side hydration payload, which is a script block, not JSX
- A third-party component uses inner HTML internally
- The app renders markdown, and the markdown renderer emits HTML
That last one is a whole post later in this series, because it has become the single most productive XSS surface in modern applications.
The React ecosystem has been tightening here. React now integrates with the browser Trusted Types API, which forces the dangerous inner-HTML prop to receive a TrustedHTML object rather than a raw string when enforcement is on. That is a real improvement and it is worth knowing which apps have adopted it, because it tells you where not to waste time. Trusted Types reached cross-browser availability in early 2026 once Firefox shipped support, so expect to meet it in the wild. Reading and defeating client-side policy is days 20 and 21.
Impact ladder
- Informational โ reflection in script, all breakout routes closed. Note it. Serialisation code changes often.
- Low โ the template expression evaluates but you cannot reach a useful sink. Rare, but it happens in sandboxed template contexts. Still worth reporting as template injection.
- Medium โ working JS-context XSS on an unauthenticated page.
- High โ working JS-context XSS on an authenticated origin. Script-context XSS is typically already on-load, no interaction needed beyond visiting, which pushes severity up compared to a hover-dependent attribute breakout.
- Critical โ stored XSS via the hydration blob. A value you set in your own profile that serialises into a script block on a page other users load is about as clean a critical as you will find, and it is common enough to hunt deliberately.
Do not leave broken JavaScript in production. A payload that throws can break the page for real users. Prefer the expression-concatenation form that keeps the script valid, and if you injected a stored value that breaks the page, remove it immediately after your screenshot and say so in the report.
Be careful with hydration blobs on shared pages. If your display name lands unescaped in the state blob on a team page, that page is being loaded by your teammates right now. Confirm with a payload that only proves execution, capture your evidence, and clean up.
Conclusion โ steal this checklist
- Script context is productive because HTML escaping does not apply there, so developers use a separate, weaker code path.
- Test three things independently on every script reflection: quote breakout, closing-tag breakout, and template expression evaluation.
- Prefer the expression-concatenation form over statement termination โ it keeps the script valid and the page working.
- If the quote comes back backslash-escaped, test whether the backslash itself is escaped. If not, you get out.
- An HTML-encoded quote inside a script block is broken markup, not protection โ but it does close the quote route specifically.
- The HTML tokeniser is still hunting the script closing tag regardless of JavaScript syntax. Strings and comments do not protect it.
- Run
${7*7}on every script reflection. One request, high hit rate, almost never filtered. - Grep for the framework state globals and map which fields you control.
- Two independent routes in a JSON blob: break the string, or break the script element. Test both.
- Event handler attributes are HTML-then-JS: entities decode to quotes before JavaScript runs.
- Look for eval, the Function constructor, string-form timers, and document.write near your reflection. Those need no breakout at all.
- React escaping covers JSX, not hydration payloads, not href, not the inner-HTML escape hatch, not markdown.
Tomorrow: URL contexts โ href, src, the javascript bridge, and why open redirects and XSS are closer relatives than most people think.
If you Love reading my blogs. Check my Youtube Channel too.