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

  1. Your server signs a JWT with your organization's embed secret. It names a report, and can carry locked filters and user attributes.

  2. Your page gives the token to the <halo-report> element.

  3. The element opens the report at /embed/signed#token=…. The token stays in the fragment, so it never reaches a server log.

  4. 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

  1. 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 into HALO_EMBED_SECRET. Note its Secret id (kid).

  2. Get the report's id. It's under Embed in the report's Share dialog, and the report must be published.

  3. 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.

FieldValue
algHS256. Halo accepts nothing else
kidRequired. The id of the secret that signed it, from Settings, Embedding
typJWT, or leave it out
critNot supported. A token that has it is refused

The signing key is the whole secret as text, including its hes_ prefix.

Claims

ClaimTypeRequiredMeans
reportstringYesThe report's id
iatnumberYesWhen it was issued, in seconds since 1970. It can't be more than a minute in the future
expnumberYesWhen it expires, in seconds since 1970. It must be after iat, and at most 24 hours after
nbfnumberNoNot valid before this time
elementstringNoOne 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
editionstringNoOne 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
filtersarrayNoLocked filters, up to 50
attributesobjectNoUser 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 } }
]
opTakes
eq, neq, gt, gte, lt, lteOne item in values
contains, starts_withOne item of text in values
in, not_inOne or more in values
betweenTwo in values, inclusive
is_null, not_nullNothing
date_rangerange, 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 saysWhat to do
the token is signed with "…"; Halo accepts only HS256Sign with HS256
the token's header needs kidAdd the secret's id as kid in the header
the token's kid names no embed secretUse the id shown under Settings, Embedding
the token's signature doesn't matchSign with the secret that kid names, as the whole text
the token is signed with a secret rotated out more than 24 hours agoMove to the current secret
the token needs report, or exp, or iatAdd the claim
the token's exp isn't after its iatSet exp later than iat
the token's report isn't one of this organization's reportsCheck the report's id and the secret's organization
the token lasts more than 24 hoursSet exp at most 24 hours after iat
the token's iat is in the futureCheck 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 filtersCheck op and its operands against the table above
the token's element … isn't in the published reportUse an element id from the published report, or publish the report again
its locked filter on "…" matches no field in the datasets it opensCheck 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.