I am really fascinated by the idea of abusing ETag (caching mechanism built into HTTP protocol) for tracking users. It is not that I want to do in a real production application; I just appreciate how simple and clever the “trick” is. Also, not so many people seem to be aware of it.

I do not think that I can do better at explaining how ETag tracking works in general than “Cookieless cookies” article. Please take a look at it first and then (hopefully) come back here.

Instead of just admiring how clever the method is in theory, I wanted to play a little bit with the idea and create a small application to reproduce it. I decided to combine it with my desire to write some Elixir code. Hence, in this blog post I will show how to implement ETag tracking in a Phoenix application.

After running mix phoenix.new we can jump straight to the code. Our model is very simple:

defmodule ETagTracker.Visitor do
  use ETagTracker.Web, :model

  schema "visitors" do
    field :token, :string
    field :visits, :integer


  def changeset(struct, params \\ %{}) do
    |> cast(params, [:token, :visits])
    |> validate_required([:token, :visits])

We have to store a token associated with every visitor. And of course we can store some additional information, in this case the number of previous visits.

The template that we want to render as the main (and only) page is:

  Number of previous visits: <%= @visits %>

And the interesting code is in the controller:

def index(conn, _params) do
  visitor = find_or_initialize_visitor(conn)

    Visitor.changeset(visitor, %{visits: visitor.visits + 1})

  |> put_resp_header("etag", visitor.token)
  |> assign(:visits, visitor.visits)
  |> render("index.html")

We use put_resp_header to set ETag header to the value that we saved in the database.

find_or_initialize_visitor function:

defp find_or_initialize_visitor(conn) do
  visitor = case get_req_header(conn, "if-none-match") do
    [value] ->
      Visitor |> Repo.get_by(token: value)
    [] ->

  visitor || %Visitor{visits: 0, token: generate_token()}

If the request has If-None-Match header set we try to find a matching record in our database. If the header is not set we treat the request as coming from a new visitor and initialize a new record.

generate_token simply returns a long, random string:

defp generate_token do
  :crypto.strong_rand_bytes(32) |> Base.encode16(case: :lower)

This is all we need! You can see the the full source code here.