Skip to content
GRUDGED Free audit
§ Field notes for operations teams ·

Sharing One Site's Analytics Without Handing Over the Whole Account

Self-hosted analytics usually scopes access by team, not by site. If every site sits in one team, adding a user shows them all of them. Share tokens are the way out.

A client asked a reasonable question: could the owner of a team I build for see the traffic stats for his site?

I self-host analytics for everything I run — his site, my own, a couple of products, and a pen name that must never be publicly connected to my consulting work. One instance, one dashboard, about ten sites. The obvious move was to create him a read-only login.

That turned out to be the one thing the platform couldn’t do.

One site belongs to exactly one team

Self-hosted analytics tools tend to model permissions as teams that own websites. In Umami, which is what I run, the websites table has a single team_id column. Not a join table. One team per site.

That constraint runs in both directions and both are painful:

  • Every one of my sites was in a single team, because that’s how you get one dashboard with everything on it. Adding him to that team shows him all ten — including the pen name.
  • Moving just his site into its own team would fix his access and break mine: my ops console pulls the site list from /api/teams/{id}/websites for one team id, so his site would vanish from my own overview.

There’s no per-site role to grant. The permission model has no opinion smaller than “team”.

Share tokens are the per-site credential

What does exist, and is easy to miss because it’s presented as a “public dashboard” feature rather than an API one: per-website share links.

Enable one and the site gets a share_id. Then:

GET /api/share/<share_id>
→ { "websiteId": "330382f6-…", "token": "eyJhbGci…" }

That token goes on subsequent calls as x-umami-share-token, and it is bound to the one website id it was minted for. I checked rather than assumed — pointed the token at a different site’s id on the same instance and got a 500. It cannot read anything else, by construction.

So instead of putting credentials for ten sites on his server, I put a credential for exactly one:

r = httpx.get(f"{UMAMI}/api/share/{SHARE_ID}", timeout=10)
token, website = r.json()["token"], r.json()["websiteId"]

stats = httpx.get(f"{UMAMI}/api/websites/{website}/stats",
                  headers={"x-umami-share-token": token},
                  params={"startAt": start, "endAt": end})

His site’s backend calls that, renders the numbers into a page behind his own login, and never sends the token to the browser. If that box were fully compromised tomorrow, the blast radius is one site’s pageview counts.

Compare that to the alternative I nearly shipped — a username and password for the analytics instance sitting in an env file, able to read every site on it.

The trade you’re making

Turning on a share id also turns on a public URL: /share/<id>/<name>. Anyone holding that link sees the dashboard without logging in.

That’s a real trade, not a free win. In practice it’s a 16-character unguessable string, functionally a bearer secret — so treat it like one. Keep it in the env file, don’t paste it in a ticket, and don’t render it into any page. Because mine is only ever used server-to-server, it never reaches a browser, so it can’t leak through a referrer header either.

If that trade doesn’t work for you, the honest fallback is a second analytics instance, not a shared login.

Two API details that cost me time

While wiring this up, both of these produced unhelpful 400 Bad Request responses rather than anything descriptive:

  • The page-path metric is type=path. type=url is the older name and now 400s. So does type=host. Working values on v3: path, referrer, country, region, city, browser, os, device, language, title, query, event.
  • stats returns a comparison object that holds the change, not the previous period’s value. The earlier figure is current − comparison. Render it as-is and your “vs last week” numbers are wrong in a way that looks plausible.

The general lesson

Before you design around a permission model, go and look at the schema. A single team_id column told me in ten seconds what an afternoon of clicking through the settings UI wouldn’t have — that the thing I was about to attempt was structurally impossible, and that I needed a different kind of credential rather than a different arrangement of the same one.


I’m Chris Moore — an independent developer and consultant in Henderson, NV, working under Grudged LLC. I wire up the reporting small teams actually look at, without handing out access nobody meant to give. If you’re stuck deciding who can see what, get in touch.

← All field notes Talk to Chris Moore →