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}/websitesfor 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=urlis the older name and now 400s. So doestype=host. Working values on v3: path, referrer, country, region, city, browser, os, device, language, title, query, event. statsreturns acomparisonobject that holds the change, not the previous period’s value. The earlier figure iscurrent − 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.