Dev.to WebDev ๐Ÿ›  Dev ๐Ÿ‘ 0 ๐Ÿ“– 3 min read

Treat UTM Parameters as User Input

Disclosure: This article was drafted with generative AI. The small JavaScript example was checked locally with synthetic inputs. It illustrates a parsing boundary, not a complete analytics service or a legal compliance g

Disclosure: This article was drafted with generative AI. The small JavaScript example was checked locally with synthetic inputs. It illustrates a parsing boundary, not a complete analytics service or a legal compliance guarantee.

A campaign parameter can contain whatever someone puts in a URL. Even if the links you create use tidy labels, someone can send a duplicate parameter, mix values from different campaigns, or paste private text into a field intended for a short identifier.

If your report only needs a known campaign label, you can put a smaller contract between that URL and your event collector: accept a registered four-part combination and emit one fixed key. Everything else gets a fixed fallback.

Match the complete combination

Checking utm_source alone leaves the remaining values unconstrained. Independently checking four lists can also accept combinations that were never registered. A source from one campaign and a content ID from another might each be valid in isolation.

Use complete tuples instead. This deliberately small example rejects missing and repeated parameters, applies a length boundary, and uses exact case-sensitive matches:

const catalog = [
  { key: 'demo_card', source: 'example_board', medium: 'organic_social',
    campaign: 'autumn_guide', content: 'comparison_card' },
];
const fields = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_content'];

function campaignKeyFor(search) {
  if (typeof search !== 'string' || search.length > 2048 ||
      /%(?![0-9a-f]{2})/i.test(search)) return 'none';
  const params = new URLSearchParams(search);
  const values = fields.map(name => params.getAll(name));
  if (values.some(v => v.length !== 1 || !/^[a-z0-9_]{1,64}$/.test(v[0]))) {
    return 'none';
  }
  const [source, medium, campaign, content] = values.map(v => v[0]);
  return catalog.find(row => row.source === source && row.medium === medium &&
    row.campaign === campaign && row.content === content)?.key ?? 'none';
}

The values above are fictional. The exact size and character limits are application choices. They should match the real labels you intend to publish, and the catalog needs its own validation for duplicate keys and tuples.

MDN documents getAll() as returning every value associated with a parameter. That matters here: ?utm_content=a&utm_content=b should not silently become whichever value a convenient single-value read returns. This example also rejects two identical copies of the same parameter, making the contract unambiguous.

Keep rejected values out of the payload

none is a classification, not a diagnostics bucket for the rejected input. Sending the fixed fallback alongside invalid_utm: originalValue would undo the boundary. The collector should validate the fixed key again and reject unexpected fields.

Likewise, a valid registered tuple plus an unrelated query parameter does not make that unrelated value part of the event. Do not serialize the URL, query object, or arbitrary page state into the payload. Test the resulting request body as well as the parser result.

Decide when the entry context starts

In a single-page application, a route change often removes the original query string. Capture the fixed key at the initial document load and keep that key in page memory while the application runs. Later route changes should not replace the entry classification.

If the user has enabled a privacy signal that your collector honors, check it before reading attribution parameters and again before sending events. For example, Global Privacy Control has limited browser availability; an absent property is not evidence of an affirmative privacy choice. Supporting a signal in one collector also says nothing about separate infrastructure logs or third-party scripts.

Page memory gives a clear lifetime: refresh starts a new context. That can lose attribution. Extending the lifetime with browser storage is a separate product and privacy decision, rather than an automatic fix for a reporting gap.

Test ambiguity and leakage

Use synthetic inputs for at least these cases:

  • One registered tuple returns its fixed key.
  • Missing, repeated, mixed, malformed, and oversized values return none.
  • An unrelated private-looking parameter never appears in an event body.
  • A route change preserves the entry key; a new document starts again.
  • An enabled privacy signal prevents collection; a failed analytics request leaves the user action usable.

Finally, keep published keys stable while their aggregates are retained. Reusing a key for another post changes the interpretation of old counts. A fixed-key catalog reduces the data your event schema accepts, but labels remain forgeable and requests can be lost. Those limits belong next to the report, where someone might otherwise mistake stage counts for a verified user funnel.

๐Ÿ“ฐ Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes โ€” full credit and traffic to the original publisher.