On Monday 2 November 2026, Utah began enforcing SB 73 against adult websites. The state’s consumer protection division says its investigators “are prepared to audit and document” whether a covered site uses reasonable age verification.
It hasn’t said what “reasonable” means. It hasn’t said how an audit runs. So you can’t study for the test. You can study the view. An auditor starts outside your system: no account, no cookies, a Utah network address, and a list of URLs. This post shows how to test your own gate from that position every week, and how to keep the result.
What we could confirm
We read the Utah Department of Commerce release of 30 October 2026, the ID Tech report of 2 November, and the KUTV report of 2 November. We haven’t read the bill text.
- The start. Statewide, Monday 2 November 2026.
- The live duty. Utah Code 78B-3-1002(1)(a): commercial adult websites must use reasonable age verification methods, so that people who reach sexually explicit content are at least 18. Commerce says a court did not block this part.
- The blocked part. Subsection (3), which deals with liability when users hide their location with a VPN (a tool that hides a user’s real network address). A federal court paused it. We covered that ruling in the geolocation post.
- Who is covered. Per Commerce, a commercial entity that knowingly and intentionally publishes material harmful to minors from a site that holds a substantial portion of it. ID Tech reports that the law sets “substantial portion” at more than one third of a site’s material.
- The audit. Commerce says investigators “are prepared to audit and document whether a commercial entity’s covered website has implemented reasonable age-verification methods.” It says it built “specialized computing and auditing infrastructure developed by enterprise technology partners.”
- The penalty. ID Tech reports administrative fines of up to $2,500 per violation, and civil penalties of the same amount. The Commerce page gives no amounts.
- The tone. ID Tech quotes a Commerce official named Busse: “We’re not looking to go and issue a lot of fines. We’re looking to make sure they are doing it.”
- The data rule. ID Tech says the law asks companies to verify age, not identity, and to delete the information used for the check immediately.
What we could not confirm
How an audit runs. What counts as one violation: a page, a visit, a user? Whether Commerce has published standards or a list of approved methods. ID Tech says the division can set standards by rulemaking, with a safe harbor for approved methods. We looked and found none yet. We also couldn’t find which court issued the injunction, or the judge.
Everything in the next section is our reasoning about what a public audit can observe. It is not Utah’s description of its method.
What an outside audit can see
An investigator who documents a site from outside is limited to what the site shows to strangers. That gives four things.
- A session with no history. No account, no cookies, no stored token.
- A Utah network address. This isn’t about VPNs. The VPN clause is on hold. It is about the plain case: someone in Utah opens your page. If your stack treats location as a guess, read our geolocation post.
- Many URLs, not one. A front page is one URL out of thousands.
- The server’s response. A screenshot can show a clean overlay. A saved copy of the page holds the HTML the server sent, whatever a script draws on top of it.
The fourth point is the one that fails most often. If your gate is a script that covers content the server already delivered, the content was delivered. We wrote about this in the client-side gate post. The fix is to not send the content until the server has seen a passed check.
Five side doors
Most gates are tested on the front page. They leak somewhere else.
| URL class | Why it leaks | What the probe does |
|---|---|---|
| Deep link to one item | The front page is gated, but item pages come from an older route that skips the check | Request it directly, with no cookies |
| Search landing page | Search engines index item pages, so visitors land on them first | Same request, with a search engine as the referrer |
| Media and preview files | A CDN (a network that serves files from nearby servers) serves a thumbnail or clip with no idea that a gate exists | Request the file URL directly |
| Sitemap, feed and list endpoints | They list items, titles and preview images | Check whether the list itself is gated, and whether its links open |
| Share and app links | A short link or in-app link opens a web view with its own route | Follow the whole redirect chain |
Public pages that aren’t explicit still need thought. For those, a safe default such as blurring works better than a gate. We covered that in the logged-out visitors post.
The probe
The code below judges one response. It doesn’t care how your gate works. It cares what the server sent.
You supply expected from your own route table: each URL is explicit or public. The probe gives one of four verdicts. gated means the server sent the user to the check, or refused, and the content is not in the body. overlay_only means the gate prompt is there, but so is the content. open means no gate at all. ok is for public pages.
overlay_only is the verdict that matters. It looks fine in a browser and fails in a saved copy.
export type Expected = "explicit" | "public";
export type ProbeResult = {
url: string;
expected: Expected;
status: number;
finalUrl: string;
hasGateMarker: boolean; // the response contains the age-check prompt
hasMediaMarker: boolean; // the response already carries the protected content
};
export type Verdict = "ok" | "gated" | "overlay_only" | "open";
// Judge only what the server sent, before any script runs.
export function verdict(r: ProbeResult, gatePath = "/age-check"): Verdict {
if (r.expected === "public") return "ok";
const blocked = [401, 403, 451].includes(r.status);
const sentToGate = new URL(r.finalUrl).pathname.startsWith(gatePath);
if ((blocked || sentToGate) && !r.hasMediaMarker) return "gated";
if (r.hasGateMarker && r.hasMediaMarker) return "overlay_only";
return "open";
}
export type ProbeRecord = { url: string; checkedAt: string; egress: string; status: number; verdict: Verdict };
export function toRecord(r: ProbeResult, egress: string, now: Date): ProbeRecord {
return { url: r.url, checkedAt: now.toISOString(), egress, status: r.status, verdict: verdict(r) };
}
export function summarize(records: ProbeRecord[]) {
const failing = records.filter((x) => x.verdict === "open" || x.verdict === "overlay_only");
return { checked: records.length, failing: failing.map((x) => x.url), pass: failing.length === 0 };
}
// Fetch with no cookies, follow redirects, and look at the body the server sent.
export async function collect(
url: string,
expected: Expected,
get: (u: string) => Promise<{ status: number; url: string; text: () => Promise<string> }>,
gateMarker: RegExp,
mediaMarker: RegExp,
): Promise<ProbeResult> {
const res = await get(url);
const body = await res.text();
return {
url, expected, status: res.status, finalUrl: res.url,
hasGateMarker: gateMarker.test(body), hasMediaMarker: mediaMarker.test(body),
};
}
These are the tests. We ran them with node --test on Node 22: 8 tests, all passing.
import assert from "node:assert/strict";
import { test } from "node:test";
import { summarize, toRecord, verdict, type ProbeResult } from "./probe.ts";
const base: ProbeResult = {
url: "https://example.test/v/123", expected: "explicit", status: 200,
finalUrl: "https://example.test/v/123", hasGateMarker: false, hasMediaMarker: true,
};
test("a 200 with the content and no gate is open", () => {
assert.equal(verdict(base), "open");
});
test("a redirect to the gate with no content is gated", () => {
assert.equal(verdict({ ...base, finalUrl: "https://example.test/age-check?next=/v/123", hasMediaMarker: false }), "gated");
});
test("a 403 with no content is gated", () => {
assert.equal(verdict({ ...base, status: 403, hasMediaMarker: false }), "gated");
});
test("an overlay on top of content the server already sent is not a gate", () => {
assert.equal(verdict({ ...base, hasGateMarker: true }), "overlay_only");
});
test("a redirect to the gate that still carries the content is not gated", () => {
assert.equal(verdict({ ...base, finalUrl: "https://example.test/age-check", hasGateMarker: true }), "overlay_only");
});
test("public pages are never judged", () => {
assert.equal(verdict({ ...base, expected: "public" }), "ok");
});
test("one open side door fails the whole run, and the record names it", () => {
const now = new Date("2026-11-03T08:00:00Z");
const gated = toRecord({ ...base, url: "https://example.test/", status: 403, hasMediaMarker: false }, "us-ut", now);
const open = toRecord({ ...base, url: "https://example.test/thumb/9.jpg" }, "us-ut", now);
const s = summarize([gated, open]);
assert.equal(s.pass, false);
assert.deepEqual(s.failing, ["https://example.test/thumb/9.jpg"]);
});
test("collect reads the body the server sent, not what a script would draw", async () => {
const fake = async (u: string) => ({
status: 200, url: u,
text: async () => '<div id="age-gate">Confirm your age</div><video src="/m/1.mp4"></video>',
});
const r = await (await import("./probe.ts")).collect("https://example.test/v/1", "explicit", fake, /id="age-gate"/, /<video|\/m\//);
assert.equal(verdict(r), "overlay_only");
});
The collect function reads the body as the server sent it. It runs no scripts. That is the point. A browser would run the script and hide the problem.
Run the probe from a network address in Utah, because that is where the audit starts. We haven’t tested providers for this, so check that the address you get is really located there. Run it weekly, and again after any deploy that changes routing or CDN rules. A CDN rule change is the most common way a side door opens.
What the record holds, and what it must not
For each URL, keep: the URL, the time, the network label you probed from, the status code and the verdict. Add the version of your deploy, so you can tie a failure to a change. That is all.
Don’t keep page bodies, and don’t keep anything about real users. ID Tech reports that the law asks for age-check data to be deleted immediately. A probe log has no user in it. It describes your own site. Keeping it apart means a deletion rule for age data never touches your audit trail.
This is the same idea as in the Australia vendor records post, seen from the operator’s side: when an authority asks, you want a dated record, not a memory. And it answers a gap we described in the testing gap post. You can’t test an age gate on a child. You can test every door without one.
What a failed run could cost
We don’t know what Utah counts as a violation. So this is an illustration, not a forecast. If each open explicit URL were one violation, then 40 open item pages at $2,500 would be $100,000 in administrative fines, and the civil claim of the same size sits on top. If Utah counts per user, the number would be larger. If it counts per site, it would be smaller.
The Busse quote points the other way: the division wants sites to comply, not to pay. A weekly record of passes, with fast fixes when a run fails, is the kind of evidence that fits that position. Whether Commerce accepts it is Commerce’s call.
What the gate itself might cost
This is an example, not customer data. Say 30,000 first-time visitors a month from Utah reach a gated page. Each one needs a browser Check, and on a paid plan a Check costs €0.01 to €0.03. That is €300 to €900. Say 10% of them (3,000) need a stronger step, a document Verification, which costs €0.10 to €0.30 on a paid plan. That is €300 to €900. The total is €600 to €1,800 a month.
The 10% is an assumption. It depends on how many visitors you can place with a Check alone. The probe itself costs a small server and an hour of setup.
Checklist for this week
- Work out whether you are covered at all. Count your explicit share. The one-third rule post explains the test as reported, and you should read the statute before relying on it.
- Write one file that marks every route prefix as
explicitorpublic. - Pick one URL from each of the five side-door classes. Fetch each with
curl, with no cookies, and read the body, not the browser view. - Search those bodies for the media tag or file URL. If the content is there and a gate prompt is there too, you have
overlay_only. - Check your CDN rules for media paths. Files should need a short-lived signed URL that your server issues only after a passed check.
- Schedule the probe: weekly, and after every deploy that touches routing or CDN rules.
- Decide who receives a failed run, and how fast it gets fixed. We would say one business day.
- Check the Commerce site for published standards or a safe harbor list. Set a date to look again.
The short version
Utah is auditing, the word “reasonable” is undefined, and the method of audit is unpublished. The only part you control is what a stranger sees. Test it the way a stranger would: no cookies, a Utah address, every class of public URL, and the server’s own response. Treat overlay_only as a failure. Keep a dated record of every run, and keep user data out of it.
Xident provides age verification and age estimation with a decision record on every check. A browser Check is the lower-cost first step, and a document Verification is for users who need a stronger answer. On paid plans a Check costs from €0.01 to €0.03, and a document Verification costs from €0.10 to €0.30. We make no claim about what Utah will accept as reasonable. Talk to us about a server-side gate for your explicit routes, or see pricing.