· 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.
ARTIFACT THAT ALERTSWHEN OPENED
ARTIFACT WEBRTC CHATMETADATA HANDSHAKE · WEBRTC DATA CHANNELClawdvert Channel Chat App Demo





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.
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.
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.

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.
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.
- 01One invitation goes to the joiner
It contains the information needed to begin a browser connection.
MEMBER → JOINER - 02One small encrypted answer comes back
The metadata endpoint carries this answer automatically.
JOINER → ENDPOINT → MEMBER - 03Both browsers begin checking routes
The automated exchange keeps the two sides close in time.
MEMBER ↔ JOINER - 04The fast encrypted connection opens
Chat, files and game state now use WebRTC, not the metadata endpoint.
WEBRTC DATA CHANNEL
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.
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.
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.