Skip to content
All Posts
Adobe TargetCSPWeb Development

CSP vs Adobe Target: The innerHTML Trap

April 15, 2025 | 5 min read | Mihai Hurjui

The Problem You’ll Hit Sooner or Later

You need to inject a form widget into a page using Adobe Target. You write a custom code offer that replaces a container’s HTML with the form markup, including the <script> tags that initialize the widget. The HTML renders perfectly — the form container appears in the DOM, styling is correct, layout looks right. But the form never initializes. No errors in the console. No widget. Just an empty div where a form should be.

This is the innerHTML trap, and it catches every Target developer at least once. It took 31 iterations to land on a reliable fix.

Why innerHTML Blocks Scripts

When you set element.innerHTML = htmlString, the browser parses the HTML and inserts it into the DOM. But any <script> tags in that HTML string are not executed. This is a browser security feature, not a bug.

Browsers block inline script execution from innerHTML to prevent Cross-Site Scripting (XSS) attacks. Scripts injected via innerHTML could be attacker-controlled, so browsers treat them as untrusted content. This behavior is part of the Content Security Policy model.

Here’s the pattern that fails:

// This does NOT work
container.innerHTML = `
    <div id="my-form"></div>
    <script>initializeForm();</script>
`;
// HTML renders, script never executes

The critical detail: there’s no error message. The script tag is silently ignored. This is why the problem is so confusing the first time you encounter it — everything looks correct in the DOM inspector.

How Adobe Target Makes It Worse

This browser behavior is particularly painful in the Target context:

  • Target’s custom code offers execute in the page context and commonly use innerHTML to replace content.
  • The Visual Experience Composer uses innerHTML-like injection under the hood for DOM modifications.
  • Form-based composer offers that inject complex HTML (forms, widgets, embedded apps) almost always include script tags.
  • The problem compounds when the widget requires multiple scripts loaded in sequence — a loader script, then the main script, then a configuration call.

You can change text, swap images, or update CSS through innerHTML without issues. The trap only springs when your injected HTML includes <script> tags.

The 3-Step Fix

The working pattern separates HTML injection from script execution entirely.

Step 1 — Inject HTML Structure Only

const HTML_STRUCTURE = `
    <div class="form-wrapper">
        <div class="form-container">
            <div id="my-form"></div>
        </div>
    </div>
`;
container.innerHTML = HTML_STRUCTURE;

Strip all <script> tags from the HTML. Only static markup goes through innerHTML.

Step 2 — Load Scripts Programmatically

function loadScript(src) {
    return new Promise((resolve, reject) => {
        const script = document.createElement('script');
        script.src = src;
        script.onload = resolve;
        script.onerror = reject;
        document.head.appendChild(script);
    });
}

// Load scripts in sequence
await loadScript('https://cdn.example.com/widget/loader.js');
await loadScript('https://cdn.example.com/widget/main.js');

This works because scripts created via document.createElement('script') and appended to the DOM do execute. The browser treats programmatically created scripts differently from innerHTML-injected ones.

Step 3 — Configure the Widget Programmatically

// Give scripts time to bootstrap
await new Promise(resolve => setTimeout(resolve, 500));

// Call the widget initialization directly
document.loadWidgets([{
    instanceId: "my-form",
    formId: "form-12345",
    locale: "en-us",
    design: { theme: "light", columns: 3 }
}]);

The configuration that would have been in an inline <script> tag becomes a direct function call. No innerHTML involved.

Handling Async Dependencies

External scripts load asynchronously, so timing matters:

  • Chain script loading with Promises. The loader script must complete before the main script loads.
  • Use script.onload callbacks for reliable sequencing, not arbitrary timeouts.
  • Add a safety delay between script load and widget initialization (500ms is usually sufficient for bootstrap).
  • Always include script.onerror to catch CDN failures or blocked scripts.

The Complete Pattern

Here’s the full implementation as a single cohesive script suitable for a Target custom code offer:

(function() {
    // 1. Find the target container
    var container = document.querySelector('#target-container');
    if (!container) { console.log('AT: Container not found'); return; }

    // 2. Inject HTML structure (no scripts)
    container.innerHTML = '<div id="my-form"></div>';

    // 3. Load external scripts
    function loadScript(src) {
        return new Promise(function(resolve, reject) {
            var s = document.createElement('script');
            s.src = src;
            s.onload = resolve;
            s.onerror = reject;
            document.head.appendChild(s);
        });
    }

    // 4. Chain: load -> configure -> render
    loadScript('https://cdn.example.com/loader.js')
        .then(function() {
            return loadScript('https://cdn.example.com/main.js');
        })
        .then(function() {
            return new Promise(function(r) { setTimeout(r, 500); });
        })
        .then(function() {
            initializeWidget({ target: '#my-form', id: 'form-123' });
            console.log('AT: Widget initialized');
        })
        .catch(function(err) {
            console.error('AT: Script loading failed:', err);
        });
})();

Note the var declarations and .then() chains instead of async/await — Target custom code runs in varying browser environments, and the ES5-compatible version avoids transpilation issues.

Debugging Tips

When things still aren’t working after applying the pattern:

  • Add console logging with a consistent prefix (e.g., AT:) for easy filtering in browser dev tools.
  • Check the Network tab to verify external scripts return a 200 status.
  • Verify the container element exists in the DOM before attempting innerHTML replacement.
  • Test in Adobe Target Preview mode before activating the activity.
  • Check Content-Security-Policy response headers — some enterprise sites have strict CSP that blocks even programmatic script loading from unauthorized domains.
  • If programmatic loading fails, verify the script domain is listed in the script-src CSP directive. You may need to coordinate with your security team to allowlist the CDN.

When You’ll Need This

The pattern applies anywhere you’re injecting JavaScript-dependent content through Target:

  • Replacing embedded forms (registration, contact, trial signup) via custom code offers
  • Injecting third-party widgets (chat, survey, feedback tools) into personalized experiences
  • Loading tracking or analytics scripts as part of a Target offer
  • Any custom code offer that needs to initialize a JavaScript widget after injecting HTML

If you’re only changing text, images, or CSS, you don’t need this pattern. innerHTML works fine for static content. The trap only activates when your injected HTML includes <script> tags — and once you know the fix, it’s the same three steps every time.

If you’re hitting this issue during a Web SDK migration, note that the same innerHTML behavior applies regardless of delivery method — alloy.js custom code offers face the identical constraint. The 3-step pattern above is the reliable approach for any script-dependent content.

Written by Mihai Hurjui

Adobe Experience Platform Consultant

More Posts