Signed embeds
Show a Halo report inside your own product. Your server signs a short-lived token that names the report, its locked filters and the reader's attributes.
A signed embed puts a published report in your own product, with each reader seeing their own rows. Your readers don't need a Halo account. Your server decides who they are, and vouches for it with a token.
How it works
Your server signs a JWT with your organization's embed secret. It names a report, and can carry locked filters and user attributes.
Your page gives the token to the
<halo-report>element.The element opens the report at
/embed/signed#token=…. The token stays in the fragment, so it never reaches a server log.The page sends it with every call as
Authorization: Bearer. Halo checks it each time, adds the locked filters, and applies your row policies with the token's attributes.
Signed embeds always read live data, never a snapshot. They show only the published version.
Set up
Make a signing secret. Under Settings, Embedding, choose Make a secret. You need to be an Owner or Admin (
org.manage). Halo shows the secret once, so copy it to your server, such as intoHALO_EMBED_SECRET. Note its Secret id (kid).Get the report's id. It's under Embed in the report's Share dialog, and the report must be published.
List your site. In the same place, add your site under Sites it may be embedded on. See Allowed sites.
The token
A signed embed token is a JWT of at most 16 KB.
Header
| Field | Value |
|---|---|
alg | HS256. Halo accepts nothing else |
kid | Required. The id of the secret that signed it, from Settings, Embedding |
typ | JWT, or leave it out |
crit | Not supported. A token that has it is refused |
The signing key is the whole secret as text, including its hes_ prefix.
Claims
| Claim | Type | Required | Means |
|---|---|---|---|
report | string | Yes | The report's id |
iat | number | Yes | When it was issued, in seconds since 1970. It can't be more than a minute in the future |
exp | number | Yes | When it expires, in seconds since 1970. It must be after iat, and at most 24 hours after |
nbf | number | No | Not valid before this time |
element | string | No | One element to show instead of the whole report, by id, up to 40 characters. The token then gets that element and what it holds, with the page's filters and the controls that reach it, not the rest of the report |
edition | string | No | One edition to show, by its slug such as 2026-03, or latest, the default. It reads current data as of that edition's date, under the token's filters |
filters | array | No | Locked filters, up to 50 |
attributes | object | No | User attributes, up to 50 keys |
Halo forgives clocks that disagree by a minute. An hour is a good lifetime for most tokens.
Sign a token
Run this on your server, never in a browser. Settings, Embedding has these examples, and Go and PHP ones, filled in with your secret's id.
// npm install jsonwebtoken
import jwt from "jsonwebtoken";
export function haloEmbedToken(user) {
return jwt.sign(
{
report: "REPORT_ID",
// element: "revenue-by-month",
filters: [{ field: "region", op: "in", values: user.regions }],
attributes: { regions: user.regions },
},
process.env.HALO_EMBED_SECRET,
{ algorithm: "HS256", keyid: "SECRET_ID", expiresIn: "1h" },
);
}jsonwebtoken adds iat itself, and works out exp from expiresIn.
# pip install pyjwt
import os
import time
import jwt
def halo_embed_token(regions: list[str]) -> str:
now = int(time.time())
return jwt.encode(
{
"report": "REPORT_ID",
# "element": "revenue-by-month",
"filters": [{"field": "region", "op": "in", "values": regions}],
"attributes": {"regions": regions},
"iat": now,
"exp": now + 3600,
},
os.environ["HALO_EMBED_SECRET"],
algorithm="HS256",
headers={"kid": "SECRET_ID"},
)Use the token
<script type="module" src="https://cdn.usehalo.net/halo-report.js"></script>
<halo-report token="SIGNED_EMBED_TOKEN" origin="https://{org}.usehalo.net"></halo-report>Always set origin to your organization's Halo address. Left out, the element uses the script's own origin, which is the CDN's.
A plain iframe works too, at https://{org}.usehalo.net/embed/signed#token=…, but it can't resize itself or talk to your page.
Locked filters
Filters in the token are added to every query, and a reader can't remove them. A report's own filters are enforced the same way only when they're locked. If the report and a token both name a field, both apply, so a row has to pass both, and a token can only narrow what the report allows. Report filters that aren't locked are applied by the reader's browser, so they don't limit what can be read. Each filter in the token is a filter on a dataset field, by the field's name rather than its label:
[
{ "field": "region", "op": "in", "values": ["North", "South"] },
{ "field": "order_date", "op": "date_range", "range": { "kind": "absolute", "start": "2026-01-01", "end": "2026-06-30" } },
{ "field": "order_date", "op": "date_range", "range": { "kind": "relative", "preset": "last_n_days", "n": 30 } }
]op | Takes |
|---|---|
eq, neq, gt, gte, lt, lte | One item in values |
contains, starts_with | One item of text in values |
in, not_in | One or more in values |
between | Two in values, inclusive |
is_null, not_null | Nothing |
date_range | range, absolute or relative |
The report shows them as fixed. A control on a locked field shows its value with a lock, and locked filters no control shows are listed above the report. If your page tries to set a locked field, it gets a filter_locked error.
User attributes
Attributes are what row policies read as {{user.<key>}}. Each key takes a string or a list of strings, up to 500 values of up to 500 bytes each. Keys are up to 64 characters, and should match the user attributes your policies name.
{ "regions": ["North", "South"], "account": "acme" }Policies fail closed. A reader whose token has no values for an attribute a policy reads sees no rows. The token gives the reader attributes only, so they have no role or groups, and a policy that compares group names has nothing to match.
Expiry
An expired token gets invalid_token on its next call, and the embed asks the reader to reload. If it runs out while the report is open, your page hears halo:error once, on the next query. Mint a token per page load, and for long sessions set a fresh one on the element's token attribute, which reloads the frame.
Rotate the secret
Under Settings, Embedding, choose Rotate. Halo makes a new secret and shows it once. Tokens signed with the current one keep working for 24 hours, so your servers can move over. Rotating again cuts off any older secret at once. Every rotation is in the audit log.
Which sites may frame it
A signed token rides in the URL's fragment, which Halo's servers never see, so the check happens in the page. It asks Halo which sites the report allows, and refuses to draw on any other, showing "This report can't be shown here" with embed_domain_not_allowed.
It knows the parent page from the browser's ancestor origins, or else the referrer. Where a browser reveals neither, a signed embed draws on any page, and the token is the gate. Someone who copies a token can use it elsewhere until it expires, so keep lifetimes short.
When a token is refused
The public API answers 401 invalid_token, with the reason in detail ("The token isn't valid: …"). In an embed, your page hears halo:error.
| The reason says | What to do |
|---|---|
| the token is signed with "…"; Halo accepts only HS256 | Sign with HS256 |
| the token's header needs kid | Add the secret's id as kid in the header |
| the token's kid names no embed secret | Use the id shown under Settings, Embedding |
| the token's signature doesn't match | Sign with the secret that kid names, as the whole text |
| the token is signed with a secret rotated out more than 24 hours ago | Move to the current secret |
| the token needs report, or exp, or iat | Add the claim |
| the token's exp isn't after its iat | Set exp later than iat |
| the token's report isn't one of this organization's reports | Check the report's id and the secret's organization |
| the token lasts more than 24 hours | Set exp at most 24 hours after iat |
| the token's iat is in the future | Check your server's clock |
| the token expired at … | Mint a new one |
| the token isn't valid until … | Wait for nbf, or leave it out |
| the token's filters[…] isn't a filter, or the token's locked filters aren't filters | Check op and its operands against the table above |
| the token's element … isn't in the published report | Use an element id from the published report, or publish the report again |
| its locked filter on "…" matches no field in the datasets it opens | Check the field's name, and that the report (or the element) uses a dataset with it |
For the rest of what a call can answer, see The public report API.