Skip to content
‹ BLOG

· 21 MIN

The Clawdvert Channel

Claude Artifacts launched as a preview in June 2024 and have since spread across Anthropic's products, including Claude Code. In Claude Code, they turn session output into hosted interactive pages. Claude Code Artifacts block the usual cross-origin request paths: their pages cannot load off-site assets or contact other sites through fetch, XHR, WebSockets, EventSource or forms. In this post, I show how one narrow gap can turn a shared Code Artifact into a canary token or bootstrap a WebRTC chat application.

A Code Artifact starts private. Making it public is a separate sharing step. Private and shared Code Artifacts run with the same browser restrictions. They cannot normally load from or send data to arbitrary outside sites. WebRTCWEBRTCBrowser technology for real-time audio, video and data between endpoints. It can use a direct path when the network allows.SOURCE · RFC 8825 ↗ appears to be blocked as well.

An iframeIFRAMEAn HTML element that loads another page inside the current page. The embedded page gets its own browsing context.SOURCE · HTML STANDARD ↗ lets one web page load another page inside it. I added an empty iframe to the Artifact. It looked blank, but the browser treated it as a new page and gave it a fresh copy of WebRTC. The Artifact could use that copy even though WebRTC was unavailable on its main page.

When a WebRTC connection is initiated, the page supplies the addresses of any STUN or TURNSTUN & TURNWebRTC helper protocols. STUN lets a browser learn its public-facing network address. TURN supplies a relay when a direct route does not work.STUN · RFC 8489 ↗TURN · RFC 8656 ↗ servers the browser should try. I used the working WebRTC copy from the iframe to initiate a connection with my own server in that list. The browser contacted it automatically while setting up the connection, giving the Artifact a narrow path out while ordinary web requests were still blocked.

For a canary tokenCANARY TOKENDefensive bait carrying a unique identifier. If that identifier reaches the operator, the bait executed or was used. The alert does not prove whether the opener was a person, an agent or a scanner.SOURCE · HONEYTOKEN ↗, the Artifact only needs to signal that it ran. For Clawdvert Channel (covert channelCOVERT CHANNELA communication path that uses a mechanism not intended to carry the message. Here only the small TURN-metadata setup path is covert; the later WebRTC data channel is ordinary WebRTC.SOURCE · COVERT CHANNEL ↗), the same narrow path returns enough setup data for a faster WebRTC connection carrying messages, files and games.

You can find a copy of all the code referenced in this post here: github.com/peterhanily/clawdvert_channel.

ONE NARROW GAP. TWO USE CASES.
CLAUDE CODE ARTIFACT<uuid>.frame.claudeusercontent.com
ARTIFACT MAIN REALMWEBRTC APIREMOVED
CHILD about:blank REALMWEBRTC APIAVAILABLE
1ARTIFACT THAT ALERTSWHEN OPENED
2ARTIFACT WEBRTC CHATMETADATA HANDSHAKE · WEBRTC DATA CHANNEL

Clawdvert Channel Chat App Demo

The pairing screen
Pairing, running as a public Code Artifact in a signed-out private window.
A chat room with connection diagnostics
A room on a direct data channel. Transport reads Direct WebRTC, the ICE path is host to host, and no TURN relay is in use.
An image sent over the data channel
The normal WebRTC data channel moving a 1.9 MB image at about 40 MB/s in one direct host-to-host test with no TURN relay. This is not the metadata carrier.
The Flappy Clawd game
Flappy Clawd. Every online claw flies the same course and the game state travels over the peer mesh.
The Clawd Combat game
Clawd Combat. Coordinated by the longest-lived member, so it survives the coordinator leaving.

How a Code Artifact reaches the browser

Claude Code publishes an Artifact as a hosted, interactive page. When you view one through Claude, the Artifact appears inside claude.ai, but its code comes from a different address.

The examples here were created through Claude Code. Artifacts made in an ordinary Claude chat use a different creation route, but both are part of the same broader Artifact platform. Where that difference matters, I call it out.

The viewer lives on claude.ai, but the page itself is loaded inside it from <uuid>.frame.claudeusercontent.com. Each Code Artifact gets its own address and browser originBROWSER ORIGINThe URL scheme, hostname and port that a browser uses as a security boundary between sites.SOURCE · RFC 6454 ↗. The browser’s same-origin policy keeps its page and storage separate from other Artifacts and from claude.ai. That separation is sound: one Artifact cannot reach into another.

A PAGE INSIDE A PAGE
claude.aithe Claude website you openedCLAUDE CODE ARTIFACT<uuid>.frame.claudeusercontent.coma separate page loaded inside an iframe
The Code Artifact appears inside Claude, but the browser treats it as a separate web page with its own address.

The Code Artifact build I tested served the page under a tight Content Security PolicyCONTENT SECURITY POLICYBrowser-enforced rules delivered with a page that limit what it can load, run or contact.SOURCE · W3C CSP3 ↗. Ordinary requests to other sites were blocked. Connector calls are different: the Artifact hands those to claude.ai, which makes the request on the viewer’s behalf.

Publishing and browser-policy details

The undocumented API calls Claude Code Artifacts frames. Deploying one is a single request to api.anthropic.com, which returns a UUID slug and a version. The viewer URL is then built from that slug. Deploying cannot set visibility. On the personal account I tested, a separate permissions request made one reviewed version public.

Those are the Claude Code creation and sharing routes I used for these tests. Artifacts created in an ordinary Claude chat use another creation flow and public URL. I did not use that flow for these demos.

I wrote a small client for the deploy endpoint, clawdvert/publish.py, which publishes a file from the command line and is used throughout the examples that follow. Claude Code would not publish these test pages on a loop for me. Watching what it sent was enough to write the client instead. The token and account are mine. The client removes the conversation from the test loop, not an authorisation boundary.

This is the relevant part of the policy I measured:

default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' blob:;
connect-src 'self';
form-action 'self';
object-src 'none';
webrtc 'block';
frame-ancestors 'self' https://claude.ai https://*.claude.ai

connect-src 'self' stops fetch, XHR, WebSocket and EventSource reaching another host. form-action 'self' prevents a form posting outward. The webrtc 'block' line appears to close the remaining WebRTC route.

Test environment

Tests ran in August 2026 using a personal account and the Claude Code Artifact surface. I reproduced the realm behaviour in Chromium and WebKit-based browsers, including signed-out viewing and a browser-to-browser connection. The roughly 40 MB/s figure below comes from one 1.9 MB direct host-to-host transfer with no TURN relay. It is a demonstration result, not a benchmark.

The narrow gap

WebRTC is the browser feature used for calls, browser-to-browser file transfers and other real-time traffic. Ask the Code Artifact’s main page for RTCPeerConnection, the object that starts those connections, and you get undefined. The route looked closed.

The block is added by JavaScript to that page alone. A blank iframe is another page with a fresh set of browser features. Its copy of RTCPeerConnection is still there and works. The Artifact can use that copy instead.

Realm and browser edge cases

Two things are meant to close WebRTC off. The served policy includes webrtc 'block', a directive defined in the current CSP Level 3 Working Draft. The Chromium build I tested did not enforce it. What does the work is a script in the page:

Object.defineProperty(globalThis, "RTCPeerConnection", {
  value: undefined, writable: false, configurable: false
});

globalThis is the catch. It names the globals of one JavaScript realm, and a realm is simply the set of built-ins a piece of code can see: its own Object, its own Array, its own RTCPeerConnection. Every browsing context gets a fresh one. An iframe’s RTCPeerConnection is a different object from its parent’s, so blanking the parent’s copy leaves the child’s untouched.

Append an iframe with no src and the browser creates an about:blank page inside it. The parent can read RTCPeerConnection from that child window and use the working constructor. The lockdown script runs once when the document loads and never revisits frames made afterwards, so nothing removes the child’s copy. about:blank, srcdoc and same-origin blob: children can be read directly by the parent. data: and sandboxed opaque-origin children need code to run inside the child and communicate back. Every child-realm form I tested still had a live constructor. Reapplying the overwrite from a MutationObserver would not help either, because a page can create a child and use it in the same task, before any observer callback runs.

The script does try to check itself. It counts the constructors it failed to remove and posts __frame_rtc_lockdown_failed to its parent if that count is not zero. But it takes the count in the realm where the overwrite just succeeded, so the answer is always zero. It never fired once in testing, while every child realm I tested held a live constructor. The control cannot observe its own failure.

Chrome is trialling a browser-level control called Connection Allowlists. It covers WebRTC and is enforced by the browser rather than by JavaScript inside the page. That is the property this overwrite lacks. It is still an origin trial, not a control that sites can assume is generally available.

The overwrite changes one JavaScript realmJAVASCRIPT REALMA JavaScript environment with its own global object and built-in values. A new browsing context receives a new realm.SOURCE · ECMASCRIPT ↗, while the page can create another. Any JavaScript control that removes a capability from only one realm has the same weakness. Enforcement that has to hold against the page cannot live in the page.

Clawdary Tokens

Clawdary Tokens use only the outbound half of this route. They answer one question: did this Artifact run in a browser that could reach my server? That needs one bit, not a channel.

A canary token is bait: content placed somewhere it should never legitimately be read, so that any access at all is a high-signal alert. Defenders plant them in file shares, repositories and mailboxes. A shared Artifact makes a plausible bait object because it looks like a document and carries an unguessable URL. There are sites where you can generate your own, like Thinkst’s canarytokens.org, and Tracebit have written about using them against rogue AI agents.

The version in this post points the recovered WebRTC constructor at a STUN or TURN server you control. A unique token in the server hostname identifies the bait. The DNS lookupDNS LOOKUPThe step that turns a name such as example.com into a network address. Here the unique name also identifies the canary.SOURCE · RFC 1034 ↗ carries the token, while the STUN or TURN server records the public source address and the time of the request. The server either sees the signal or it does not. No reply channel is needed.

A CLAWDARY TOKEN FIRING
INSIDE THE SANDBOX YOUR INFRASTRUCTURE someone opens the bait artifact appends an iframe with no src child realm returns a constructor points it at stun:<token>.your-zone ICE gathering starts resolves the hostname STUN binding request authoritative DNS logs the query, names the bait your STUN server UDP from the viewer's public address
The token is unique to each piece of bait, so the lookup names which artifact was opened. The DNS query arrives from the viewer's resolver rather than from the viewer, while the STUN packet carries the viewer's public source address and port as the server observes it, which is not the same as their machine behind NAT or a VPN. No HTTP request is made; the signal is carried entirely by DNS resolution and ICE setup traffic.

The signal proves that the Artifact ran in a browser able to reach the server. It does not tell you whether the opener was a person, an agent or an automated scanner, and it will not fire where the required WebRTC traffic is blocked.

I also built an inert example canary token that demonstrates the browser signal. It does not alert me or send the result to a server I control. It uses Cloudflare’s public STUN server and shows the result in the page.

A public Claude Code Artifact in a signed-out private window. fetch is blocked by connect-src while a STUN server answers in 48 ms, and the page shows the public endpoint and round-trip measurements it read back.

The inert demo uses Cloudflare's public STUN server, not a server I operate.

The TURN metadata channel

STUN and TURN are connection helpers used by WebRTC. STUN tells a browser how it appears from the public internet. TURN relays traffic when two browsers cannot find a direct route to each other.

The small channel reuses two fields from the TURN exchange. The browser sends a username that the server can read, so the outgoing message goes there. A normal TURN reply gives the browser a relay address. My signalling endpoint returns a synthetic address containing a few reply bytes instead.

That synthetic address is not a working relay route and the signalling endpoint does not forward chat traffic. A separate coturn service provides the real relay later if the browsers cannot connect directly.

THE SMALL ROUTE IN AND OUT
NORMAL TURNbrowserTURN serverusernameworking relay addressTHIS CHANNELCode Artifactmy endpointsmall message inside usernamesmall reply inside address fieldThe address-shaped reply carries a few bytes back.
The signalling endpoint returns a few bytes through a field the browser already understands. It does not carry the later chat or file traffic.
Protocol details and prior work

TURN authenticates with a username and a password. The password does not travel as plaintext. It contributes to the message-integrity key. The username travels in the USERNAME attribute and reaches the server verbatim, so the message goes in the username.

A normal TURN server grants an allocation and returns the working transport address it assigned, four bytes of IPv4 and two of port. My signalling endpoint speaks enough TURN to make the browser expose those six bytes as an ICE candidateICE CANDIDATEOne possible network address that WebRTC can try while connecting two endpoints.SOURCE · RFC 8445 ↗, but the address is synthetic.

The signalling endpoint encodes its reply into that address-shaped field:

const bytes  = new Uint8Array([...ip, (port >> 8) & 255, port & 255]);
const header = bytes[0];
if (header >= 21 && header <= 25)
  return { kind: "data", payload: bytes.slice(1, 1 + header - 20), final: false };

One header byte, up to five payload bytes. The client opens six lanes on six UDP ports, so a polling cycle carries thirty bytes inbound. Both halves live inside protocol metadata the endpoints have to exchange anyway. At this point there is no data channel. The reply is not carried to the address. The reply is the address.

Prior art

This TURN carrier is not new. In July 2024 Yifan Gu published turnx alongside a write-up that demonstrated both halves: outbound bytes in the TURN username and inbound bytes decoded from an IPv6 address and port. It chunked messages for the same purpose: moving enough data to bootstrap a WebRTC connection. What I added here is the Claude Code Artifact child-realm gap, an IPv4 return path and a signalling protocol that uses the slow channel only long enough to open a WebRTC data channel. DNS and ICMP tunnelling are older members of the same family.

In December 2025 Sobele reported bulk exploitation of verified users in the Claude.ai artifact system, which put browser fingerprints in the TURN username and chunked them around the same length limit I ran into. Two things separate it from this one. It ran on the standard chat Artifact surface, where RTCPeerConnection was simply available and there was nothing to get around, and it needed a viewer who was signed in. Anthropic closed it as intended behaviour and a duplicate of an older WebRTC report.

The slow path opens the fast one

In practice the metadata channel moved about 12 bytes per second. That is too slow for practical chat or file transfer, but it is enough to return one small answer that lets two browsers open a WebRTC data channelWEBRTC DATA CHANNELA message channel between WebRTC endpoints for application data such as chat, file chunks or game state.SOURCE · RFC 8831 ↗.

One person copies an invitation to the joining browser. The small answer comes back automatically through the metadata endpoint. Both browsers wait until they have what they need, then the WebRTC connection starts and carries the actual application.

THE METADATA HANDSHAKE
  1. 01
    One invitation goes to the joiner

    It contains the information needed to begin a browser connection.

    MEMBER → JOINER
  2. 02
    One small encrypted answer comes back

    The metadata endpoint carries this answer automatically.

    JOINER → ENDPOINT → MEMBER
  3. 03
    Both browsers begin checking routes

    The automated exchange keeps the two sides close in time.

    MEMBER ↔ JOINER
  4. 04
    The fast encrypted connection opens

    Chat, files and game state now use WebRTC, not the metadata endpoint.

    WEBRTC DATA CHANNEL
The metadata endpoint has one small job: return the answer. The application traffic starts after that job is finished.
THE SLOW PATH OPENS THE FAST ONE
1 · SMALL SETUP PATH ABOUT 12 B/s Returns one small encrypted answer through TURN metadata. enough to start the connection 2 · WEBRTC DATA CHANNEL ≈40 MB/s · ONE DIRECT TEST · NO TURN Carries chat, files, rooms and games after the browsers connect. The bars show the difference in purpose. They are not drawn to scale.
The slow route only makes the introduction. Once the encrypted answer returns, the WebRTC data channel takes over and carries the application.
The exact signalling sequence

I measured a median round trip of 7 ms to my signalling endpoint in AWS eu-west-1. All fifteen probes answered. That puts the network ceiling near 4.3 kB/s. The client polls every 160 ms while active and every 850 ms while idle, which brings the ceiling down to about 190 bytes per second. In practice the channel managed about 12 bytes per second. IPv6 has a wider address field and would lift the same six lanes from 30 to 102 bytes per cycle, but even that is too slow for practical chat or file transfer. It is enough to return the small WebRTC answer needed to open the faster data channel.

An existing member copies one full CC2. invite to the joining browser. It contains the WebRTC offer, the inviter’s gathered ICE candidates and a one-use answer secret. The joiner removes and holds the inviter’s candidates, applies a candidate-free copy of the offer, creates a compact answer, authenticates and encrypts it for that invitation, and writes it to one short-lived rr2 slot.

The inviter verifies and applies the answer before acknowledging the slot. The acknowledgement lets the joiner apply the inviter’s retained candidates and begin connectivity checks. That ordering matters. In the older manual version the phone could start checking routes while a person was still carrying the answer back, and the useful checks could be over before the inviter received it. This keeps the two sides close enough in time that the useful checks are no longer exhausted during a manual answer exchange. The application’s link-hello over the data channel confirms that the new browser joined the right room.

The configured build I tested returned the answer automatically. The public checkout leaves the rr2 host blank, so it uses the manual answer exchange until an operator configures a metadata endpoint.

A separate Artifact mailbox

I also built a communications channel that does not use WebRTC. The private prototype treats two Code Artifacts as mailboxes: clawdvert/mailbox.py. Two computers signed into the same account can use the pair to exchange messages. Each client writes to one private Artifact and reads the one its peer updates. The Artifacts are inert versioned blobs. The two command-line clients do the reading and writing.

Using a shared mutable blob as a mailbox is old, and people have done it with Gist, Pastebin, DNS TXT records and Google Docs revision history.

A separate public arrangement is possible, with a different Claude account on each computer. It is not the mailbox prototype above. In the public-sharing configuration I tested, the shared link was pinned to one reviewed version. Each publish therefore needed a second permissions request before a signed-out reader saw the update. The product also exposes an Always share latest version setting, which I did not use for this prototype. Anthropic can still attribute the Artifact and its updates to the account that owns it.

PRIVATE PROTOTYPE AND PUBLIC VARIANT
PRIVATE PAIR one write step device A artifact A artifact B device B publish read publish read Both ends signed into one account, so each reads the other's private artifact directly. PUBLIC PAIR · PINNED MODE TESTED two write steps in this mode device A artifact A shared = v2 artifact B shared = v7 device B publish then move pin read publish read Reads can be signed out, but until the dashed step runs the reader stays on the version before.
The implemented mailbox uses two private Code Artifacts on one account. In the pinned public-sharing mode I tested, the separate public variant needed a second permission request to move the version pin, plus the review delay before a new version could be shared.

Detecting this on your own network

The WebRTC channel and the Artifact mailbox leave different traces.

Team and Enterprise owners can disable Artifacts or external sharing. Publishing, sharing and deletion also appear in the organisation audit log as claude_artifact_* events.

PREVENTION & DETECTION
LAYER PREVENT WITH DETECT WITH Claude administration Team and Enterprise disable Artifacts or external sharing claude_artifact_* events publish, share, delete the artifact page not yours whoever serves the page the browser disable_non_proxied_udp blocks this direct UDP path net-export capture local file, not managed the host firewall rule on the binary content filter on macOS Sysmon 3, ETW tracing PKTAP, filter flows DNS protective DNS resolver and NS logs earliest signal network egress allowlist STUN and TURN by host and transport Zeek, Suricata cleartext only The page-level trick does not change which browser process sends the packet. Product controls, browser policy, host controls, DNS and network monitoring each see a different part of the path.
Start with the Claude controls you operate, then work down through the browser, host, DNS and network layers.

The private Artifact mailbox is the harder of the two because it uses the same Anthropic API and Code Artifact content hosts as ordinary work. Its writes and metadata reads are authenticated, and the private content is fetched with a short-lived asset token. What separates it is cadence rather than content. Someone working with Claude publishes in bursts and then stops. An active mailbox polls regularly and repeatedly publishes new versions. Compare that pattern against the account’s own baseline rather than against a fixed number.

The metadata channel is easier to spot in the form I built. It repeatedly sends cleartext UDPUDPA lightweight transport that sends independent packets without first establishing a connection.SOURCE · RFC 768 ↗ to the same unusual destination. A normal WebRTC connection gathers candidates in a burst and then settles down. This one keeps polling while it is active.

Wire-level detection details

STUN messages and TURN control messages such as Allocate use the same 20-byte STUN header containing the fixed magic cookie 0x2112A442. The message type differs between a STUN Binding request and a TURN Allocate request, but the cookie is present in both. TURN ChannelData is a separate format. That makes the cleartext control traffic straightforward for a network sensor to identify. This implementation uses cleartext UDP. The carrier could be adapted to TURNS, which puts TURN inside TLS or DTLS, but the repository does not demonstrate that mode. It would hide the STUN/TURN attributes from passive inspection, not the destination, timing, duration, volume, polling pattern or usually the DNS lookup.

Once you can see STUN, four things are worth alerting on.

  • Where it goes. Most organisations either have a known set of STUN and TURN destinations or have none at all. An allocation request to an unrecognised destination is worth investigating on its own, and the destination correlates with the DNS lookup that preceded it.
  • How much of it there is. Candidate gathering is normally bursty, and an established session uses comparatively sparse connectivity and consent traffic. This client builds six fresh allocations per polling cycle and keeps going, which is roughly thirty-eight a second while it is busy.
  • How long the username is. The message is the username, so it runs to hundreds of bytes of base64, where production TURN credentials are usually far shorter and often timestamp-shaped.
  • What happened in DNS just before. When the metadata endpoint is configured by hostname, its lookup normally lands before the first STUN or TURN exchange. Where an implementation varies the name per message or per token, as the canary does, that is the same shape as any DNS tunnel.

Control is cheaper than detection here. Allowlist approved STUN and TURN destinations by host and transport. Chrome’s WebRtcIPHandling=disable_non_proxied_udp is a routing restriction, not a WebRTC kill switch. It blocks this implementation’s direct non-proxied UDP path in the tested configuration, but WebRTC can still use an available proxy transport. Test the result against the browser builds and proxy configuration you deploy. The child-realm recovery does not create a distinct process or executable, so host and network detections have to focus on the resulting traffic.

Responsible Disclosure

I reported this to Anthropic through their bug bounty programme. The first report covered the child-realm constructor recovery and the DNS and STUN traffic it enabled. I followed up with a bi-directional demonstration: bytes left in the TURN username and server-selected bytes returned through the address field exposed as an ICE candidate.

Anthropic closed the report as Informative. Their position is that the constructor lockdown is a best-effort defence in depth measure rather than a boundary the sandbox’s isolation model relies on. That classification explains why they did not treat the behaviour as a vulnerability. It does not change the technical result documented here. Any errors are mine.

The tooling is at github.com/peterhanily/clawdvert_channel.