How Much Is an AI Watermark Worth? Part 1: Building One


Since August 2026, the EU AI Act requires AI providers to mark the content their systems generate. Google has watermarked Gemini’s text since 2024, and Anthropic announced that new Claude models released since the deadline do the same. That creates a new expectation: we can tell whether a text was written by an AI.

People will start relying on that. A company wants to flag AI-written phishing mails, a platform wants to trace a disinformation campaign, a compliance team needs evidence. Once people rely on a watermark, attackers have a reason to remove it or to fake it.

This series asks how much it takes to do either, and what a detected watermark is worth as evidence. Before I can attack a watermark, I need to understand how it works, so I am building one. This first part covers the core: how a watermark gets into a text, and how a detector finds it again.

How an LLM picks a word

A language model writes one token at a time. A token is a piece of a word with a number. The tokenizer of Qwen2.5, the model I will use later, splits “The cat sat on the mat.” like this:

785    'The'
8251   ' cat'
7578   ' sat'
389    ' on'
279    ' the'
5517   ' mat'
13     '.'

At each step the model gives every token in its vocabulary a score. For Qwen2.5 that is 151,936 scores (151,665 real tokens plus unused padding). The scores become probabilities, and the model draws one token at random, weighted by those probabilities. These are the real top candidates after “The weather today is very”:

' hot'     14.4 %
' nice'    14.4 %
' good'     8.7 %
' cold'     5.3 %
' warm'     4.1 %

“hot” and “nice” are equally likely, and a reader would accept either.

These numbers depend on the exact model and settings. They come from Qwen2.5-1.5B-Instruct in bfloat16 on a GPU, with the sentence given as plain text (no chat template) and the scores turned into probabilities directly, without temperature or other sampling settings. A larger model, the base version or a different temperature gives different percentages.

The green list

The watermark I am building follows Kirchenbauer et al. (2023). At every step, a secret key and the previous token decide which 25% of the vocabulary counts as “green”. The generator gives green tokens a small bonus, so among words that fit equally well it tends to pick a green one.

To decide the colour, I need a function that takes the key, the previous token and a candidate token and returns a number between 0 and 1. It must return the same number every time, look random to anyone without the key, and change completely when any input changes. A hash function does that:

def keyed_uniform(key: str, prev_token: int, token: int) -> float:
    message = f"{key} | {prev_token} | {token}"
    digest = hashlib.sha256(message.encode()).digest()
    number = int.from_bytes(digest[:8], "big")
    return number / 2**64

Here is every step for the key secret, the previous token “ the” (279) and the candidate “ floor” (6422):

1. message        secret | 279 | 6422
2. SHA-256        0af514e7245768c9 fb2655f36907c098 dd4d447f34cee21f 5b537e1401d498e6
3. first 8 bytes  0af514e7245768c9
4. as a number    789,560,292,653,099,209
5. divide by 2^64 789,560,292,653,099,209 / 18,446,744,073,709,551,616 = 0.043

Step 2 is 32 bytes written in hexadecimal, two characters per byte. Step 3 keeps the first 8 of them. Step 4 reads those 8 bytes as one whole number. Eight bytes can hold any whole number from 0 up to 2^64 − 1, so dividing by 2^64 in step 5 always gives a value from 0 up to just below 1. Here the number sits 4.3% of the way up that range.

The number is not the colour yet. One rule turns it into a colour: a token is green if its number is below 0.25. Because the numbers are spread evenly between 0 and 1, about a quarter of all tokens fall below 0.25, which gives the 25% green from above. In the code, gamma is that 0.25:

def is_green(key: str, prev_token: int, token: int, gamma: float = 0.25) -> bool:
    return keyed_uniform(key, prev_token, token) < gamma

For “ floor” after “ the”, the number from step 5 is 0.043. That is below 0.25, so “ floor” is green after “ the”.

One way to picture it is a table with a row for every possible previous token and a column for every candidate. Each cell holds one number. With 151,665 tokens the table would have 151,665 × 151,665, about 23 billion cells, so nobody stores it. Each cell is computed when needed. Eight real cells:

                 ' mat'   ' dog'   ' sofa'  ' floor'
after ' the'     0.564    0.142    0.618    0.043
after ' cat'     0.906    0.535    0.192    0.104

After “ the”, “ dog” and “ floor” are green. After “ cat”, “ sofa” and “ floor” are green, and “ dog” is red. Every previous token has its own green list, so the same words are not favoured everywhere in the text.

I checked that a quarter really lands below 0.25 on 100,000 candidates after the same previous token. A quarter would be 25,000. I got 25,217, which is 25.22%. I repeated the check for nine more previous tokens, including “ the” and “ cat”. The share of green tokens ranged from 24.63% to 25.22%, with an average of 24.98%.

Detection

The detector needs the key and the tokens. It does not need a language model. It walks through the text in pairs, asks for each pair whether the second token was green after the first, and counts.

For “The cat sat on the mat.” with the key secret:

'The'  -> ' cat'   0.789   red
' cat' -> ' sat'   0.723   red
' sat' -> ' on'    0.887   red
' on'  -> ' the'   0.104   green
' the' -> ' mat'   0.564   red
' mat' -> '.'      0.935   red

One green out of six. Whether that is a lot depends on what chance alone would produce. For someone who does not know the key, every token is like a coin that lands on green with a chance of 0.25 and on red with a chance of 0.75. The detector asks how unusual the count is for such coins. It needs three numbers.

Expected. With n scored pairs, about n × 0.25 of them should end on a green token. Here that is 6 × 0.25 = 1.5.

Spread. Coins rarely hit the expected count exactly. The spread says how far the count typically lands from it. For this kind of coin it is √(n × 0.25 × 0.75), the standard deviation of a count of coin flips (the binomial distribution). One pair on its own varies by 0.25 × 0.75 = 0.1875, measured as the square of the spread (statisticians call this the variance). These squared spreads add up over independent pairs, so n pairs give n × 0.1875, and the square root turns the total back into a number of tokens. Here that is √(6 × 0.1875) = √1.125 ≈ 1.06. So a text without the watermark usually has somewhere around 1.5 ± 1.06 green tokens.

z-score. How many spreads the actual count sits above the expected count:

z = (green − expected) / spread
  = (1 − 1.5) / 1.06
  = −0.5 / 1.06
  = −0.47

The count is half a green token below the expectation, which is less than half a spread. Nothing unusual. That is right, because the sentence did not come from a watermarked model.

Threshold. If you score many longer texts written without the key, their z-scores pile up around 0 in a bell curve. About 95% of them land between −2 and 2. Only high scores matter, because a watermark adds green tokens. Above 4 the curve has almost nothing left: about 3 texts in 100,000. That is why z = 4 is the usual threshold for calling a text watermarked.

Results

To test the detector without a real model, I simulated two texts of 200 tokens over a vocabulary of 50,000 and checked them three ways:

  • a “human” who picks tokens at random, without the key
  • a “watermarked model” that only ever picks green tokens
  • the same green text, checked with a different key

200 tokens give 199 pairs to score. All three checks therefore share the same expected count and spread:

expected = 199 × 0.25             = 49.75
spread   = √(199 × 0.25 × 0.75)   ≈ 6.11
              green   green − expected   z = (green − expected) / spread
human            50          0.25          0.25 / 6.11  =  0.04
watermarked     199        149.25        149.25 / 6.11  = 24.4
wrong key        52          2.25          2.25 / 6.11  =  0.37

The random text has 50 green tokens, almost exactly the 49.75 expected, so z is close to 0. The green text has 199, which is 149 above the expectation and gives z = 24.4, far above the threshold of 4. Checked with the wrong key, the same green text gets 52 green tokens and z = 0.37. With the wrong key, a watermarked text scores like human writing.

Short texts

Suppose a watermarked text is 50% green instead of 25%. This is an example calculation, not a measurement. The first column counts the pairs the detector scores, which is one less than the length of the text:

pairs    green   expected   green − expected   spread    z
     6       3       1.5                1.5     1.06    1.5 / 1.06  = 1.4
    50      25      12.5               12.5     3.06   12.5 / 3.06  = 4.1
   200     100      50.0               50.0     6.12   50.0 / 6.12  = 8.2

Going from 50 to 200 pairs, the extra green tokens grow four times, from 12.5 to 50. The spread only doubles, from 3.06 to 6.12, because it grows with the square root of the length. Four times the signal divided by twice the spread gives twice the z-score.

The same watermark stays below the threshold at 6 scored pairs, sits right at it at 50, and is clear at 200. The sentences in the first paragraph of this post are 18 to 30 tokens long, so a phishing message of two or three sentences lands close to the threshold.

And 50% green is an assumption here. A weaker watermark needs much longer texts. At 37.5% green, each token adds 12.5 points of green above the 25% that chance gives, half of the 25 points at 50%. Because the spread grows with the square root of the length, half the signal per token needs four times the length: about 190 tokens instead of about 50 to reach z = 4. How much green a real watermark produces is what the next parts measure.

Known limits

The watermark is hard, not soft. My simulated model only picks green tokens. A real one cannot do that. After “The capital of France is”, “ Paris” may well be red, and a model that refuses red tokens would have to write something wrong. Real schemes give green tokens a bonus instead of forcing them. That is the next step.

No real model yet. The watermark has not run inside a real model. All watermarked tokens so far are random numbers, not language.

Too slow for a real vocabulary. To apply the bonus, the generator needs the colour of every token at every step. In my version that is 151,936 SHA-256 calls per token, which takes too long in Python. Kirchenbauer et al. avoid this by seeding a random generator once per step instead of hashing every token.

One token of context. The green list depends only on the previous token. The open-source version of Google’s SynthID-Text uses four by default.

Repetition is counted. If a text repeats the same phrase twenty times and the pair happens to be green, the detector counts it twenty times. Human text with repetition can look suspicious that way. The fix is to count each pair only once, which I will add in a later part.

What I learned

The key is not in the text

When I first saw keyed_uniform(key, prev_token, token), I asked whether the key would be something like “ the”. It is not. The previous token and the candidate come from the text, and anyone reading the text can see them. The key is the one input that does not come from the text, and the whole scheme depends on that. If the key were a word, an attacker could try every word in a dictionary against the detector until the score jumps. A real key is a long random string.

Green does not mean likely

I assumed that a green token was one the model would pick, and I could not see what a random colour had to do with choosing the right word. I had two separate steps mixed up. First, the key paints a random quarter of the vocabulary green. That says nothing about any word. Second, the generator prefers green among the words that already fit. Only the second step makes green words more frequent, and only in watermarked text.

The colour has to be meaningless for the scheme to work. If green meant “good word”, humans would pick green words too, and the detector would flag them. Because the label is random, someone who does not know the key lands on green about 25% of the time, whatever they write, and that is the number the detector compares against.

The same mix-up was behind my question why we set 25% for humans. We don’t. We set that 25% of the vocabulary is green. Humans land at about 25% as a consequence, because they choose words without knowing the colours.

A missing watermark proves little

The wrong-key result changed how I read “no watermark found”. Each provider has its own key, so a platform that wants to detect AI text needs every provider’s key or detector. Anthropic, for example, offers its detector only as an API in private preview. A negative result can mean a human wrote the text, or a model whose key you do not have. It can also mean the model does not watermark at all, or the text is too short for the signal to show. Whether someone can also wash the watermark out on purpose is what the later parts of this series test.

Nobody publishes their settings

After building the green list, I wanted to know how Anthropic chooses its version of the 25%. Anthropic says its watermark is a version of SynthID-Text, the scheme Google DeepMind published in Nature in 2024. SynthID-Text uses the same idea of a random label and a detector that scores the text, but picks tokens differently, through a tournament between candidates instead of a bonus. The settings Anthropic uses are not public. Only the open-source version of SynthID-Text documents its defaults.

A September 2026 paper, Watermarks Without Verification, argues that the claims about deployed watermarks cannot currently be checked from the outside. Anyone who relies on a detector result has to trust settings they cannot see.

Anthropic also limits what a positive result means. According to its post, a watermark can only show that Claude “was likely involved with the content at some point”, and it cannot tell “Claude wrote this” from “Claude heavily edited this”. Both points make it harder to say what a detected watermark is worth, which is the question this series is about.

What comes next

The soft version: green tokens get a bonus, and I measure how the signal depends on the size of that bonus and on how many good options the model has. After that, the watermark goes into a real model, and the attacks follow.

The code is at github.com/DilaraKoc1/inkwash.