Self-hosted service
askides/aurora avatar
askides/aurora

Aurora: cookie-free web analytics you host yourself

Zero cookies, anonymous open source web analytics.

562 stars16 forksTypeScriptMIT

At a glance

What is it?
Aurora is a self-hosted analytics tool that keeps no cookies, no localStorage and no fingerprint on a visitor's device, identifies people with a hash that rotates every day, and writes every event into a Postgres database you control. The script that ships to the browser is 2.4 KB, and the parts of the project that show the most care are the ones about time zones and about not trusting forwarded headers.
Who is it for?
Aurora fits a site operator who wants traffic numbers without a consent banner and without handing visitor data to a third party, and who is willing to run a container and a Postgres database. It is the wrong choice if you need the segmentation depth of a commercial analytics product, since the dashboard covers a fixed set of breakdowns and custom events rather than an arbitrary query builder, and the wrong choice if you do not want to operate a database.
Can I use it commercially?
Yes. MIT is a permissive licence: you can use, modify and sell software built on it, as long as you keep its copyright and licence notices.
Is it still maintained?
Yes. The repository last received commits 79 days ago.
What is it written in?
Mainly TypeScript, according to GitHub's language statistics.

Answers come from the project's GitHub data, last synced on October 22, 2026, and from our analysis. They are not legal advice.

Editorial analysis

Analytics with nothing left on the visitor's machine

Aurora is described as open, cookie-free website analytics you host yourself, and the repository description calls it zero cookies, anonymous open source web analytics.

The mechanism is stated in the opening paragraph. Aurora measures your traffic without storing anything on your visitors' devices, no cookies, no `localStorage` and no fingerprinting, and keeps every event in a Postgres database you control. The tracking script is 2.4 KB over the wire.

Those two halves are what makes it different from the hosted tools. Analytics services usually need a client-side identifier so that a returning visitor can be recognised, and that identifier has to live somewhere in the browser, and a stored identifier is what triggers consent requirements. Aurora removes the identifier, and with it the reason for a banner.

The legal reasoning is spelled out in the README's section on why cookie-free matters. ePrivacy Art. 5(3) governs storage of information in terminal equipment, and a `localStorage` key counts just as much as a cookie does. Aurora writes to neither, so there is no consent banner to show for it.

That is a narrower claim than it might appear, and the README is careful about it. It removes the storage, not the processing. The server still sees the IP address and the user agent of every request, and it uses both. What changes is that nothing that leaves the browser can be traced back to a person.

Identifying a visitor with a hash that changes every day

How do you count unique visitors without storing an identifier? Aurora's answer is a rotating HMAC.

Visitors are identified by a rotating HMAC derived from the IP address, user agent and a server-side salt, never by a stored identifier. The hash input rotates daily, so yesterday's visitor id cannot be linked to today's, and nothing that leaves the browser can be traced back to a person.

The daily rotation is the part that does the work. Without it, a stable hash would be a pseudonym, and a pseudonym is a stable identifier that you happen to have hashed instead of stored. With it, each day's identifier is derived from a different input, so the day-to-day link that would let you build a profile of one person across a week simply does not exist.

The cost is equally plain. You can tell how many distinct visitors there were today and how many there were yesterday, and you cannot connect them. A returning visitor counts as a new one. That is the trade the design makes, and a project evaluating analytics tools should be clear that it is a trade rather than a free win.

The salt is what stops the hash from being reversible. AURORA_SALT is required in production and the README says that without it the visitor ids would be derivable by anyone, so boot fails. A hash of an IP address is trivially reversible by brute force, since the address space is small, and the salt turns that into a keyed operation that an outsider cannot perform.

One container and one signup

The quick start is a single docker run, and it is worth reading closely because the two random secrets are generated inline:

bash
docker run -d --name aurora -p 3000:3000 \
  -e DATABASE_URL=postgres://user:password@host:5432/aurora \
  -e SESSION_SECRET="$(openssl rand -base64 32)" \
  -e AURORA_SALT="$(openssl rand -base64 32)" \
  ghcr.io/askides/aurora:latest

The images are published for linux/amd64 and linux/arm64, and the README says to use `:latest` or pin a version, with `:4`, `:4.0` and `:4.0.0` all valid, while `:edge` tracks `main` and is not stable.

That pinning advice matters for two of the secrets in that command. The salt is what makes the visitor hashes unguessable, so regenerating it between restarts would silently change every visitor id and reset your unique visitor counts. The session secret signs the session cookie, so regenerating it logs everyone out. Neither is recoverable from the database, which means losing the salt loses the ability to link ids across days even if you wanted to.

After the container starts, you open `http://localhost:3000/signup`, create an account and add a site. The port mapping in that command is the web interface and the collection endpoint together, which is why the tracker snippet later points at the same host.

The header configuration is where the numbers can be forged

Five environment variables are documented, and one of them carries a warning that is worth reading twice.

DATABASE_URL is required and is the Postgres connection string, used at runtime and for migrations. SESSION_SECRET is required and signs the session cookie. AURORA_SALT is required in production and is the HMAC salt for visitor ids. AURORA_COUNTRY_HEADER is optional and is the header your edge writes the country code into, when it is not one of the three known ones.

The fifth, AURORA_IP_HEADER, is marked strongly recommended, and the README gives it a paragraph of its own.

Every forwarding header is client-supplied until some hop overwrites it, and which hop that is depends on your topology rather than on anything the request can prove. Left unset in production, Aurora warns at boot and falls back to guessing among `cf-connecting-ip`, `x-real-ip` and `x-forwarded-for`, all of which a client can set, which makes visitor, session and bounce figures forgeable. Name your trusted hop and nothing else is consulted.

That is an unusually blunt admission, and it is the most practically important line in the README. The identifiers that the whole privacy model rests on are derived from an IP address, and the IP address arrives in a header the client controls unless you configure which header your proxy rewrites. With the default fallback, a visitor can trivially rotate their apparent address and inflate your visitor counts. The fix is one environment variable, but you have to know which header your edge overwrites.

The tracker snippet and the custom event API

Adding Aurora to a site is a script tag. The dashboard generates the snippet, which you paste into your page's `<head>`:

code
<script async defer src="https://your-instance/tracker.js" aurora-id="YOUR_SITE_ID"></script>

The script is served by your own instance, the site id travels as an attribute, and pageviews including client-side route changes are tracked automatically. That last point matters for a single page application, where a conventional analytics script would record one pageview for the whole session unless you told it about route changes.

Custom events go through a global function, and the README calls out one property of it that saves a lot of debugging:

js
aurora("signup");
aurora("purchase", {
  props: { plan: "pro" },
  revenue: { amount: 49.0, currency: "EUR" },
});

The global queues calls made before the script has loaded, so it is safe to use immediately. Without that behaviour, any event fired in the first moments of a page load would be lost, since the script is loaded with async defer and there is no guarantee about when it finishes.

The payload rules are short and specific. Props take scalars only, up to 24 keys, and revenue currencies are ISO-4217. The limit on props is the kind of constraint that exists because the values end up in a Postgres column set that someone chose in advance, and it is worth respecting, since a payload that exceeds it will not fail loudly.

What the dashboard shows, and the public share link

The dashboard covers the usual ground: visits, unique visitors, sessions, bounce rate and average session duration over a range you pick, in a timezone you pick, on a Recharts timeseries.

The breakdowns are where the coverage is defined. They cover pages, referrers, acquisition channels, countries, browsers, operating systems, devices, languages and the full set of UTM parameters, plus custom events with their props and revenue.

That list is a fixed set of dimensions rather than an open query interface, and that is the main functional difference from a commercial analytics product. If you need to slice a metric by an arbitrary combination of properties, you will not find that here. If you need the standard set of acquisition and technical breakdowns plus your own events, you will.

One feature deserves attention on its own. Any site can be flagged public, which exposes its dashboard read-only at `/websites/:id/s/analytics` without a login.

That is a deliberate product decision rather than an oversight, and it cuts both ways. It makes sharing a traffic report with a colleague or a client trivial, with no account to create and no credential to pass around. It also means that the site id is the only thing standing between your traffic data and anyone who has it, so treat the public flag as a publishing decision for each site rather than a default to reach for.

The dashboard image in the README shows pageviews, daily visitors, sessions, bounce rate and average visit duration over a 30-day range, above a daily pageviews chart and the top pages and acquisition channels.

The timezone pin, and a repository built with care

The Development section asks for Node 20 and pnpm 10, and the setup sequence is:

bash
git clone https://github.com/askides/aurora
cd aurora
pnpm install

docker compose up -d --wait postgres      # dev Postgres on host port 5434
cp apps/web/.env.example apps/web/.env
pnpm db:migrate
pnpm dev

The comment above that sequence is the most interesting part of the README, because it explains a pin that would otherwise look arbitrary. The compose Postgres is pinned to a patch release on purpose. Aurora resolves a window's boundaries in JS and groups the buckets inside it in SQL, so Node's tzdata and Postgres's have to agree about a zone or the two halves of a chart disagree by that zone's offset, silently. Both sides assert their own copy against `AURORA_TZDATA`, and the instruction is to move them together or neither.

The Dockerfiles take that seriously. The base image is pinned to node:22.23.2-alpine rather than node:22, because the tzdata release is a property of the Node version and is compiled in, so the tag fixes `process.versions.tz` exactly, while the database's copy comes from its image's OS packages. The Dockerfile takes AURORA_TZDATA as a build argument and runs a Node one-liner that compares it against process.versions.tz and exits with an error if they differ, so a mismatch fails at build time instead of months later at a transition. The compose file pins postgres:16.13 for the same reason, and carries a warning that recreating the container is required for the pin to take effect on an existing one.

The repository layout is a pnpm workspace with one deployable app and the script it serves, apps/web as the React Router application with the dashboard, the /collect endpoints and the tracker host, and packages/tracker as the browser script bundled to apps/web/public/tracker.js. The app code is organised as feature modules, analytics, auth, ingest and websites, over a shared layer, and the dependency direction is one-way and enforced by oxlint, with shared not permitted to import from modules or shell. The stack is React Router in framework mode, Drizzle on PostgreSQL, Tailwind CSS with shadcn/ui, Recharts, Vitest, and oxlint with oxfmt.

The licence is MIT and the recent releases are v4.0.2, v4.0.1 and v4.0.0, all on 2026-08-05, with the last push on the same day. Commits follow Conventional Commits with all-lowercase subjects, enforced by a commit-msg hook and again in CI, and releases are automated with release-please reading those commits.

Editorial conclusion

Aurora fits a site operator who wants traffic numbers without a consent banner and without handing visitor data to a third party, and who is willing to run a container and a Postgres database. It is the wrong choice if you need the segmentation depth of a commercial analytics product, since the dashboard covers a fixed set of breakdowns and custom events rather than an arbitrary query builder, and the wrong choice if you do not want to operate a database. Verify first which hop overwrites your client IP header and set AURORA_IP_HEADER accordingly, because the fallback guesses among headers a visitor can set, and keep the Postgres image and the Node base image on the same tzdata release, because the build asserts it and a mismatch fails the build rather than the chart.

Frequently asked questions

What is Aurora analytics and does it use cookies?

Aurora is an open source, self-hosted web analytics tool that stores nothing on a visitor's device: no cookies, no localStorage and no fingerprinting. Every event goes into a Postgres database you control, and the tracking script is 2.4 KB over the wire.

How does Aurora count unique visitors without cookies?

Visitors are identified by a rotating HMAC derived from the IP address, the user agent and a server-side salt, and the hash input rotates daily so yesterday's visitor id cannot be linked to today's. No identifier is ever stored, and the AURORA_SALT is required in production because without it the ids would be derivable by anyone.

How do I add the Aurora tracker to my website?

Paste the snippet the dashboard gives you into your page's head, for example a script tag pointing at https://your-instance/tracker.js with an aurora-id attribute holding your site id. Pageviews including client-side route changes are tracked automatically, and custom events go through the global aurora() function, which queues calls made before the script has loaded.

What does AURORA_IP_HEADER do and why does it matter?

It names the forwarding header your proxy overwrites, for example cf-connecting-ip. The README explains that every forwarding header is client-supplied until some hop overwrites it, so if the variable is unset in production Aurora warns at boot and falls back to guessing among cf-connecting-ip, x-real-ip and x-forwarded-for, all of which a client can set, which makes visitor, session and bounce figures forgeable.

Can I share an Aurora dashboard without creating an account?

Yes. Any site can be flagged public, which exposes its dashboard read-only at /websites/:id/s/analytics without a login. The dashboard itself covers visits, unique visitors, sessions, bounce rate and average session duration, with breakdowns for pages, referrers, acquisition channels, countries, browsers, operating systems, devices, languages and UTM parameters.

Official sources

  1. askides/aurora on GitHub
  2. Issues
  3. License: MIT
  4. README
  5. Releases
Add this badge to your README

If you maintain this project, the badge below links readers to this analysis and shows its maintenance status from the daily GitHub snapshot. Paste the markdown into your README; add ?metric=license or ?metric=stars to the image URL for a different field.

Add this badge to your README

markdown
[![Hysen Labs](https://hysenlabs.com/badge/askides-aurora.svg)](https://hysenlabs.com/projects/askides-aurora)

Was this useful?