Embedded report authentication tokens that never trust a client supplied id
An embedded report authentication token is a short lived credential your server signs after checking the session, carrying the tenant identifier as a claim. The analytics layer verifies the signature, reads the claim, and applies it as a mandatory filter. Nothing the browser sends can change which rows come back.
A sales engineer was demonstrating the exhibitor portal to a prospect and clicked into the stand performance report. The address bar ended in ?exhibitor_id=4471. Out of habit more than curiosity he changed the last digit to 4472 and pressed enter. The page redrew with another company's lead counts, scan totals and follow up rates.
Nobody had been careless in an obvious way. The portal had a login, the session was real, the report was correct. The defect sat in one assumption: that the exhibitor id arriving with the request described the exhibitor making it. Embedded report authentication tokens exist to remove that assumption, and the part that matters is not the token format. It is where the identifier comes from.
What breaks when the exhibitor id travels in the URL?
The OWASP Insecure Direct Object Reference Prevention Cheat Sheet defines the vulnerability as one that "arises when attackers can access or modify objects by manipulating identifiers used in a web application's URLs or parameters", and it names three ingredients: an object, a reference identifier, and missing authorisation checks. An exhibitor report has all three the moment the tenant id is a query string.
The cheat sheet's advice is direct. Avoid exposing identifiers in URLs and request bodies where you can, and "determine the currently authenticated user from session information" instead. It also disposes of the fix most teams reach for first, which is to swap the sequential integer for a GUID. Complex identifiers "can make it practically impossible for attackers to guess valid values", but the cheat sheet is explicit that "access control checks are essential" regardless. A GUID raises the cost of guessing and changes nothing about what happens when somebody is handed a link.
That last case is the one that actually occurs in event businesses. Exhibitor reports get forwarded. A stand manager sends the link to their agency, the agency has three other clients at the same show, and now a URL containing a tenant identifier is sitting in a mailbox that belongs to a competitor's supplier. No attack was involved.
This post is about the credential. The question of how the report gets into the portal an exhibitor already logs into belongs to V19, and the storage patterns underneath, shared tables against a database per tenant, belong to V20. Both sit under the wider BI and reporting work.
The token is a claim your own server wrote
The pattern that holds up is short. The browser asks your backend for a token. Your backend checks the session it already trusts, looks up which exhibitor that user belongs to, and signs a token containing that exhibitor id as a claim. The analytics layer verifies the signature and reads the claim. At no point does the browser get to say which exhibitor it is.
Cube's documentation describes the receiving side of this precisely, calling the security context "a verified set of claims about the current user that the Cube server can use to ensure that users only have access to the data that they are authorized to access", and noting that "your authentication server issues JWTs to your client application, which, when sent as part of the request, are verified and decoded by Cube to get security context claims to evaluate access control rules" (Cube, 2026). The division of labour is the useful part. Your application knows who the user is. The analytics layer knows how to filter. The token is the only thing that crosses between them, and it is signed.
Jones, Bradley and Sakimura specified the format in RFC 7519 in 2015. Two claims carry most of the weight here. The exp claim "identifies the expiration time on or after which the JWT MUST NOT be accepted for processing". The aud claim "identifies the recipients that the JWT is intended for", which stops a token minted for your reporting endpoint from being replayed against a different service that happens to trust the same key. Section 3 is worth reading once for the reason the whole thing works: the claims are digitally signed or MACed, so a modified claim fails verification rather than arriving as a slightly different request.
How short should the token live?
Short enough that a leaked token is stale before anyone finds it, long enough that a reader does not get logged out mid report.
Ten minutes is a defensible starting point and the arithmetic behind it is worth doing. Watch what an exhibitor actually does in the portal. They open the report, look at the headline scan count, switch the date filter from the full show to day two, sort the lead table, and export to Excel. That is four to six requests over perhaps ninety seconds of attention, spread across five or six minutes of real time because they are also reading. A ten minute lifetime covers that session twice over with room for a slow export.
Now the mint rate. A show with 640 exhibitors, of whom 180 log in during the fortnight after close, each opening the report an average of 2.4 times, gives 432 sessions. At one token per session that is 432 signatures across fourteen days, roughly 31 a day. Signing a token is a few milliseconds of work. There is no performance argument for a long lifetime, and the only real cost of a short one is the refresh logic in the client, which you have to write once.
Set the clock skew allowance explicitly while you are there. Sixty seconds is generous for servers you control and it prevents the support ticket where one reader's tokens are rejected because a container's clock drifted.
Filtering happens at the data layer or it does not happen
A verified token with no filter behind it produces a correctly authenticated request that returns every row in the table. This is the failure that survives a security review, because the review looks at authentication and the defect is in authorisation.
The mandatory filter has to be applied somewhere the report author cannot forget it. Cube's documentation points at query_rewrite for exactly this, saying "you can use query_rewrite to enforce mandatory filters that apply to all queries". Whatever your stack, the property to insist on is that the filter is central and not per report. If a new report can be published without the predicate, one eventually will be.
Three surfaces get missed with monotonous regularity. Saved views, because the filter was applied when the view was created and the view is then loaded by id. Scheduled exports, because they run without an interactive session and somebody wired them to a service account. And any raw API the portal exposes for the download button, which frequently sits one layer below the reporting tool and inherits none of its rules.
Proving that each of those surfaces filters correctly is a separate discipline with its own matrix of test users and expected row counts, which V15 covers. Writing the filter and testing the filter are different jobs and they should not be done by the same person on the same afternoon.
Working the numbers on one token, one exhibitor
Take the concrete shape. A token minted at 09:14:02 with exp at 09:24:02, an aud of your reporting endpoint, and a claims body holding a single exhibitor id, 4471, plus the user id that requested it.
The reporting layer verifies the signature, reads 4471, and rewrites every incoming query to include exhibitor_id = 4471. A request for the lead table returns that exhibitor's 1,284 leads. A request that arrives with exhibitor_id=4472 in the client payload still returns 1,284 rows for 4471, because the rewrite is applied after the client filter and the client filter is intersected with it. That intersection is the behaviour to test for. An empty result is the right answer when a reader asks for somebody else's rows, and an error message that says the exhibitor id was rejected is a worse answer, because it confirms that 4472 exists.
Keep the user id in the token even though it plays no part in filtering. It is what makes the query log answer the question you will eventually be asked, which is who ran the export that ended up in a competitor's inbox.
Keep almost everything else out. A claims body is base64 encoded and readable by anyone holding the token, so it is a poor place for a contracted rate, an email address or an internal account note. Two identifiers and an expiry are usually the whole payload. If the reporting layer needs to know that this exhibitor bought the sponsorship package, it should look that up from the exhibitor id rather than be told by the token, because a lookup can be revoked and a claim written ten minutes ago cannot.
One more number worth having. If a single exhibitor account is shared by four people at an agency, as happens constantly, your 432 sessions come from fewer than 432 humans and your query log cannot separate them. That is an argument for individual portal logins mapped to one exhibitor, rather than one shared credential per stand, and it costs nothing at token time because the exhibitor id claim is identical either way.
Where this stops
A short lifetime bounds exposure. It does not revoke anything. If you disable a user at 09:15 and their token expires at 09:24, they have nine minutes of valid access to a report you meant to close. For most exhibitor reporting that is an acceptable window. For a report containing contracted rates it might not be, and the answer then is a revocation check on each request, which trades a lookup per query for the guarantee. Pick one deliberately and write down which.
The second limit is that none of this constrains what the reader does with data they are entitled to see. An exhibitor with a legitimate token can export their own leads and forward the file. Tokens control the boundary between tenants. They have no view of what happens after a correct answer leaves the building.
Start by grepping your portal's embed URLs and API calls for anything that looks like a tenant identifier: exhibitor_id, account, company, tenant. For each one you find, change the value by hand while logged in as a test exhibitor and see what comes back. The list of URLs that return somebody else's rows is your work queue, and it usually takes an hour to produce.
Questions people ask about embedded report authentication tokens
- Why should the exhibitor id not be a URL parameter?
- Because a URL parameter is under the reader's control and can be edited in the address bar. The OWASP Insecure Direct Object Reference Prevention Cheat Sheet advises avoiding identifiers in URLs and request bodies, and determining the authenticated user from session information instead. An identifier that arrives from the client is an input to be checked, never an authorisation to be honoured.
- How long should an embedded analytics token live?
- Long enough for one report session and no longer. Ten minutes covers a reader opening a report, changing a filter and exporting, and it bounds the damage if the token leaks into a log or a shared screenshot. RFC 7519 defines the exp claim as the time on or after which the token must not be accepted, so the expiry is enforced by the verifier.
- Does a signed token remove the need for row filters?
- No. The token establishes who is asking. Something still has to turn that claim into a predicate on every query, including saved views, scheduled exports and any API the portal exposes. A verified claim with no filter behind it produces a correctly authenticated request that returns the whole table.
Related reading
- Embedded analytics for exhibitors inside the portal they already log into
- Multi tenant analytics isolation so one exhibitor never sees another set of rows
- Testing row level security before one exhibitor can see another exhibitor data