Talking to a different service
Most islands talk to your
Phoenix backend over live
or api. This one doesn't: the agenda comes from a
separate service
(a stand-in for Microsoft Graph). The pattern works whenever your app and
that service trust the same identity provider
(here, imagine Entra) — so a token minted for the signed-in user is
accepted by both. Phoenix mints it; the island spends it directly.
The token's journey
-
Mint (server).
On each page render the root layout signs a short-lived, per-user token
and drops it into the page-wide runtime
contextundertokens.graph— context, not payload: a page-wide credential, minted once, the same for every island on the page. -
Embed.
<.runtime>serializes the context into one JSON<script>in the page. It rides in the HTML the user already received — so keep it short-lived and narrowly scoped, never a long-lived secret. -
Read (client).
The island receives that context at its boundary and reads
context.tokens.graph— no round-trip, it's already there. -
Spend.
The island calls the third party with its own
fetchand anAuthorization: Bearerheader. Phoenix is never in that request's path — no proxy, noapihelper, no channel. -
Verify (the other service).
The service checks the bearer and answers. No session or CSRF is
involved — that's the whole reason this can't go through
api. -
Expire → re-auth.
The embedded token is a render-time snapshot, so it eventually expires.
A
401is the island's signal to get a fresh one (re-mint via a small session-protected endpoint, or a LiveView push) — flip the "simulate expired token" checkbox in the demo to see the401→ re-auth path.
The three sides, in code
1 · Phoenix mints the token into context
# root.html.heex — minted once, per user, into the page-wide runtime context
<KeenPhoenixSvelte.runtime context={%{
user: %{id: @current_user.id, name: @current_user.name, email: @current_user.email},
# A short-lived, per-user bearer for a *different* service. In a real app this
# is an on-behalf-of token for graph.microsoft.com; here it's a Phoenix.Token.
tokens: %{graph: Phoenix.Token.sign(ExampleWeb.Endpoint, "graph token", @current_user.id)}
}} />
2 · The island spends it with its own fetch
// calendar/js/App.svelte — the island calls the third party with its OWN fetch,
// using the token from context. No `api`, no `live` — nothing Phoenix-specific.
let { context } = $props();
$effect(() => {
fetch("/mock-graph/v1.0/me/calendarView", {
headers: { Authorization: `Bearer ${context.tokens.graph}` },
})
.then((r) => (r.status === 401 ? reauth() : r.json()))
.then((data) => (events = data.value));
});
3 · The other service verifies the bearer
# RequireGraphToken — what the "third-party" service does with the bearer.
# No session, no CSRF: it's a different service reached with a token, which is
# exactly why the island uses its own fetch instead of the `api` helper.
with ["Bearer " <> token] <- get_req_header(conn, "authorization"),
{:ok, user_id} <- Phoenix.Token.verify(Endpoint, "graph token", token, max_age: 3600) do
assign(conn, :graph_user_id, user_id)
else
_ -> conn |> put_status(:unauthorized) |> json(%{error: %{code: "InvalidAuthenticationToken"}}) |> halt()
end
Why deliver it this way
The island hits the service straight from the browser — your backend
isn't a relay in the hot path, and the same mount works on the plain
/calendar-plain
page with no live at all.
Because it lives in the DOM, the token is short-lived,
per-user, and scoped to just this service. A downstream
secret
would never go here — that stays server-side, behind api.
Full write-up: the Runtime context guide covers relaying user identity and shared tokens, including token freshness.