The signing endpoint, in four languages and seven frameworks
Identity handshake — code samples
Every sample here mints the same thing: a signed, single-use, 120-second statement that a particular person is signed in to your product. Pick your language, copy the one that matches, and the rest of this page is reference.
If you have not read the contract yet, the short version is: Beacon never decides who someone is — your backend does, and signs a statement saying so. The signature is what stops a page script claiming to be somebody else and reading their organisation's support history.
The samples on this page are executed by the test suite. Each dependency-free one is run for real and the assertion it produces is put through the same verification path a live request takes. They are not illustrative; if one of them stopped producing an acceptable assertion, the build would fail.
The one thing that catches everyone
The HMAC key is the SHA-256 digest of your signing secret, in raw bytes. Not the secret itself, and not the digest's hex string.
key = SHA256(signing_secret) # 32 raw bytes
Beacon stores the digest rather than the secret, and verifies with what it stores. Sign with the secret directly and every assertion is refused — with the same generic refusal as a forged one, because telling a caller why a signature failed is a hint worth withholding. That is one line in every sample below and it is the line to check first when nothing works.
The claims
| Claim | Value | Notes |
|---|---|---|
iss | anything identifying your portal | required, not otherwise checked |
aud | "beacon" | exact string |
sub | your stable user id | opaque to us; a returning user is recognised by it |
name | display name | optional, refreshed on every handshake |
email | their address | required |
org_id | your organisation/tenant id | decides who shares ticket history |
project_id | your Beacon project id | must match the one in boot() |
iat | now, seconds | |
exp | iat + 120 | 120 seconds is the maximum accepted |
jti | a fresh UUID every time | single-use; a repeat is refused |
Algorithm is HS256 and nothing else. none is rejected before a claim is read.
Minting, on your server
JavaScript / Node.js — no dependencies
node:crypto has everything. No package, no supply chain.
import { createHash, createHmac, randomUUID } from "node:crypto";
// The SHA-256 digest of the secret is the key. Not the secret.
const KEY = createHash("sha256").update(process.env.BEACON_SIGNING_SECRET).digest();
const b64 = (value) => Buffer.from(value).toString("base64url");
export function mintAssertion({ projectId, issuer, user }) {
const now = Math.floor(Date.now() / 1000);
const header = b64(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const payload = b64(
JSON.stringify({
iss: issuer,
aud: "beacon",
sub: user.id,
name: user.fullName,
email: user.email,
org_id: user.organisationId,
project_id: projectId,
iat: now,
exp: now + 120,
jti: randomUUID(),
}),
);
const signature = createHmac("sha256", KEY)
.update(`${header}.${payload}`)
.digest("base64url");
return `${header}.${payload}.${signature}`;
}
Already using jsonwebtoken or jose? Keep them — pass the same KEY above as the secret and set algorithm: "HS256". The derivation is the part that matters.
Express
app.post("/support-identity", requireLogin, (req, res) => {
res.json({
assertion: mintAssertion({
projectId: process.env.BEACON_PROJECT_ID,
issuer: "acme-portal",
user: req.user,
}),
});
});
requireLogin is yours. This endpoint must answer only for the person already signed in to your product — it is the entire authentication step, and an endpoint that takes a user id from the request body is an endpoint that mints an identity for anybody who asks.
Next.js — App Router
// app/support-identity/route.js
import { NextResponse } from "next/server";
import { auth } from "@/lib/auth";
import { mintAssertion } from "@/lib/beacon";
// Never static. A cached assertion is a replayed one, and the second use is refused.
export const dynamic = "force-dynamic";
export async function POST() {
const session = await auth();
if (!session?.user) {
return NextResponse.json({ error: "unauthenticated" }, { status: 401 });
}
return NextResponse.json({
assertion: mintAssertion({
projectId: process.env.BEACON_PROJECT_ID,
issuer: "acme-portal",
user: {
id: session.user.id,
fullName: session.user.name,
email: session.user.email,
organisationId: session.user.organisationId,
},
}),
});
}
BEACON_SIGNING_SECRET must not be prefixed NEXT_PUBLIC_. A public secret is not a secret, and anyone holding it can mint an identity for any of your users.
Python — no dependencies
Standard library only.
import base64
import hashlib
import hmac
import json
import os
import time
import uuid
# The SHA-256 digest of the secret is the key. Not the secret.
KEY = hashlib.sha256(os.environ["BEACON_SIGNING_SECRET"].encode()).digest()
def _segment(payload: dict) -> str:
raw = json.dumps(payload, separators=(",", ":")).encode()
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
def mint_assertion(*, project_id: str, issuer: str, user: dict) -> str:
now = int(time.time())
header = _segment({"alg": "HS256", "typ": "JWT"})
payload = _segment(
{
"iss": issuer,
"aud": "beacon",
"sub": user["id"],
"name": user["full_name"],
"email": user["email"],
"org_id": user["organisation_id"],
"project_id": project_id,
"iat": now,
"exp": now + 120,
"jti": str(uuid.uuid4()),
}
)
signed = f"{header}.{payload}".encode()
signature = (
base64.urlsafe_b64encode(hmac.new(KEY, signed, hashlib.sha256).digest())
.rstrip(b"=")
.decode()
)
return f"{header}.{payload}.{signature}"
With PyJWT
If pyjwt is already in your requirements, this is the same thing in one call:
import hashlib, os, time, uuid, jwt
KEY = hashlib.sha256(os.environ["BEACON_SIGNING_SECRET"].encode()).digest()
def mint_assertion(*, project_id: str, issuer: str, user: dict) -> str:
now = int(time.time())
return jwt.encode(
{
"iss": issuer, "aud": "beacon", "sub": user["id"],
"name": user["full_name"], "email": user["email"],
"org_id": user["organisation_id"], "project_id": project_id,
"iat": now, "exp": now + 120, "jti": str(uuid.uuid4()),
},
key=KEY,
algorithm="HS256",
)
Django
# views.py
from django.http import JsonResponse
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_POST
@require_POST
@login_required
def support_identity(request):
return JsonResponse({
"assertion": mint_assertion(
project_id=settings.BEACON_PROJECT_ID,
issuer="acme-portal",
user={
"id": str(request.user.pk),
"full_name": request.user.get_full_name(),
"email": request.user.email,
"organisation_id": str(request.user.organisation_id),
},
)
})
# urls.py
path("support-identity", views.support_identity, name="support-identity"),
FastAPI
from fastapi import APIRouter, Depends
router = APIRouter()
@router.post("/support-identity")
def support_identity(user=Depends(current_user)):
return {
"assertion": mint_assertion(
project_id=settings.beacon_project_id,
issuer="acme-portal",
user={
"id": str(user.id),
"full_name": user.full_name,
"email": user.email,
"organisation_id": str(user.organisation_id),
},
)
}
current_user is your dependency and it must raise on an anonymous request. A dependency that returns None for a signed-out visitor turns this into an endpoint that mints assertions for nobody in particular.
Flask
from flask import jsonify
from flask_login import current_user, login_required
@app.post("/support-identity")
@login_required
def support_identity():
return jsonify(assertion=mint_assertion(
project_id=app.config["BEACON_PROJECT_ID"],
issuer="acme-portal",
user={
"id": str(current_user.id),
"full_name": current_user.full_name,
"email": current_user.email,
"organisation_id": str(current_user.organisation_id),
},
))
PHP — no dependencies
hash_hmac and random_bytes are enough. No Composer package required.
<?php
/**
* The SHA-256 digest of the secret is the key — note the `true` third argument,
* which returns raw bytes. Leave it out and hash() returns hex, you sign with the
* wrong key, and every assertion is refused.
*/
function beacon_key(): string
{
return hash('sha256', getenv('BEACON_SIGNING_SECRET'), true);
}
function beacon_b64(string $raw): string
{
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
function beacon_uuid4(): string
{
$bytes = random_bytes(16);
$bytes[6] = chr((ord($bytes[6]) & 0x0f) | 0x40);
$bytes[8] = chr((ord($bytes[8]) & 0x3f) | 0x80);
return vsprintf('%s%s-%s-%s-%s-%s%s%s', str_split(bin2hex($bytes), 4));
}
function beacon_mint_assertion(array $user, string $projectId, string $issuer): string
{
$now = time();
$header = beacon_b64(json_encode(['alg' => 'HS256', 'typ' => 'JWT']));
$payload = beacon_b64(json_encode([
'iss' => $issuer,
'aud' => 'beacon',
'sub' => $user['id'],
'name' => $user['full_name'],
'email' => $user['email'],
'org_id' => $user['organisation_id'],
'project_id' => $projectId,
'iat' => $now,
'exp' => $now + 120,
'jti' => beacon_uuid4(),
], JSON_UNESCAPED_SLASHES));
$signature = beacon_b64(
hash_hmac('sha256', "$header.$payload", beacon_key(), true)
);
return "$header.$payload.$signature";
}
Plain PHP endpoint:
<?php
// support-identity.php
session_start();
header('Content-Type: application/json');
if (empty($_SESSION['user_id'])) {
http_response_code(401);
echo json_encode(['error' => 'unauthenticated']);
exit;
}
$user = current_user(); // your session, your rules
echo json_encode(['assertion' => beacon_mint_assertion(
[
'id' => (string) $user['id'],
'full_name' => $user['name'],
'email' => $user['email'],
'organisation_id' => (string) $user['organisation_id'],
],
getenv('BEACON_PROJECT_ID'),
'acme-portal'
)]);
Laravel
<?php
// app/Http/Controllers/SupportIdentityController.php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Auth;
class SupportIdentityController extends Controller
{
public function store(): JsonResponse
{
$user = Auth::user();
return response()->json(['assertion' => beacon_mint_assertion(
[
'id' => (string) $user->id,
'full_name' => $user->name,
'email' => $user->email,
'organisation_id' => (string) $user->organisation_id,
],
config('services.beacon.project_id'),
'acme-portal'
)]);
}
}
// routes/web.php — inside the group that already requires a session.
Route::post('/support-identity', SupportIdentityController::class . '@store')
->middleware('auth');
Put the secret in config/services.php reading env('BEACON_SIGNING_SECRET'), and keep the env() call out of the controller — php artisan config:cache makes env() return null everywhere except config files, which is a failure that appears only in production.
Ruby — no dependencies
openssl and securerandom are in the standard library.
require "base64"
require "json"
require "openssl"
require "securerandom"
# The SHA-256 digest of the secret is the key. Not the secret.
KEY = OpenSSL::Digest::SHA256.digest(ENV.fetch("BEACON_SIGNING_SECRET"))
def beacon_b64(raw)
Base64.urlsafe_encode64(raw, padding: false)
end
def beacon_mint_assertion(user:, project_id:, issuer:)
now = Time.now.to_i
header = beacon_b64(JSON.generate({ alg: "HS256", typ: "JWT" }))
payload = beacon_b64(JSON.generate({
iss: issuer,
aud: "beacon",
sub: user[:id],
name: user[:full_name],
email: user[:email],
org_id: user[:organisation_id],
project_id: project_id,
iat: now,
exp: now + 120,
jti: SecureRandom.uuid,
}))
signature = beacon_b64(OpenSSL::HMAC.digest("SHA256", KEY, "#{header}.#{payload}"))
"#{header}.#{payload}.#{signature}"
end
Rails
# app/controllers/support_identity_controller.rb
class SupportIdentityController < ApplicationController
before_action :authenticate_user!
def create
render json: { assertion: beacon_mint_assertion(
user: {
id: current_user.id.to_s,
full_name: current_user.full_name,
email: current_user.email,
organisation_id: current_user.organisation_id.to_s,
},
project_id: Rails.application.credentials.beacon[:project_id],
issuer: "acme-portal",
) }
end
end
# config/routes.rb
post "/support-identity", to: "support_identity#create"
Booting, in the browser
getAssertion is called when the widget needs a session — the first time somebody opens the panel, and again whenever the session it has is close to expiring. It is not called once and cached, so it must be able to answer more than once, and the answer has to still be valid then rather than at page load.
Do not boot for a signed-out visitor. getAssertion has no way of saying "there is nobody here": the launcher is already on the page by the time it runs, so a visitor with no session gets a launcher that fails when they open it. Guard the boot() call with whatever your app already uses to know somebody is signed in. That is the guard shown in each sample below.
Vanilla — a script tag
<script type="module">
import { boot } from "https://cdn.beacon.inits.dev/widget/v1/loader.js";
// Your own signed-in check. Anything truthy will do — this is just the guard
// that keeps the launcher off the page for visitors who have no session.
if (window.CURRENT_USER) {
boot({
baseUrl: "https://api.beacon.inits.dev/api/v1/widget",
projectId: "your-project-id",
getAssertion: async () => {
const res = await fetch("/support-identity", {
method: "POST",
// Your session cookie. Without this the endpoint sees an anonymous
// request and refuses, and the panel cannot open.
credentials: "same-origin",
});
return (await res.json()).assertion;
},
});
}
</script>
If the fetch fails or the endpoint refuses — their session ended while the tab was open — the widget shows the customer one sentence saying it could not verify them. It never throws into your page and never touches your layout: boot() appends one element and everything it renders lives inside that element's shadow root.
React
import { useEffect, useRef } from "react";
const getAssertion = async () => {
const res = await fetch("/support-identity", {
method: "POST",
credentials: "same-origin",
});
return (await res.json()).assertion;
};
export function SupportWidget({ projectId, user }) {
// A ref, not state: booting twice would mount two widgets, and React 18's
// development StrictMode runs effects twice on purpose.
const booted = useRef(false);
useEffect(() => {
if (!user || booted.current) return; // the signed-in guard
booted.current = true;
let widget;
import(
/* webpackIgnore: true */ "https://cdn.beacon.inits.dev/widget/v1/loader.js"
)
.then(({ boot }) =>
boot({
baseUrl: "https://api.beacon.inits.dev/api/v1/widget",
projectId,
getAssertion,
}),
)
.then((instance) => {
widget = instance;
});
return () => widget?.destroy();
}, [projectId, user]);
return null; // the widget renders itself, into its own shadow root
}
boot() resolves to null when the widget declines to start: a URL rule excludes this page, the project is deactivated and set to hide rather than explain itself, or the config request did not come back. Hence widget?.destroy() rather than widget.destroy(). None of those are errors and none of them touch your page — nothing has been appended at the point it gives up.
Vue 3
<script setup>
import { onMounted, onBeforeUnmount, ref } from "vue";
const props = defineProps({
projectId: { type: String, required: true },
user: { type: Object, default: null },
});
const widget = ref(null);
onMounted(async () => {
if (!props.user) return; // the signed-in guard
const { boot } = await import(
/* @vite-ignore */ "https://cdn.beacon.inits.dev/widget/v1/loader.js"
);
widget.value = await boot({
baseUrl: "https://api.beacon.inits.dev/api/v1/widget",
projectId: props.projectId,
getAssertion: async () => {
const res = await fetch("/support-identity", {
method: "POST",
credentials: "same-origin",
});
return (await res.json()).assertion;
},
});
});
onBeforeUnmount(() => widget.value?.destroy());
</script>
<template><!-- nothing: the widget renders into its own shadow root --></template>
Svelte
<script>
import { onMount, onDestroy } from "svelte";
export let projectId;
export let user = null;
let widget = null;
onMount(async () => {
if (!user) return; // the signed-in guard
const { boot } = await import(
/* @vite-ignore */ "https://cdn.beacon.inits.dev/widget/v1/loader.js"
);
widget = await boot({
baseUrl: "https://api.beacon.inits.dev/api/v1/widget",
projectId,
getAssertion: async () => {
const res = await fetch("/support-identity", {
method: "POST",
credentials: "same-origin",
});
return (await res.json()).assertion;
},
});
});
onDestroy(() => widget?.destroy());
</script>
Angular
import { Component, Input, OnDestroy, OnInit } from "@angular/core";
@Component({ selector: "app-support-widget", template: "", standalone: true })
export class SupportWidgetComponent implements OnInit, OnDestroy {
@Input() projectId!: string;
@Input() signedIn = false;
private widget: { destroy(): void } | null = null;
async ngOnInit(): Promise<void> {
if (!this.signedIn) return; // the signed-in guard
const { boot } = await import(
/* webpackIgnore: true */ "https://cdn.beacon.inits.dev/widget/v1/loader.js"
);
this.widget = await boot({
baseUrl: "https://api.beacon.inits.dev/api/v1/widget",
projectId: this.projectId,
getAssertion: async () => {
const res = await fetch("/support-identity", {
method: "POST",
credentials: "same-origin",
});
return (await res.json()).assertion;
},
});
}
ngOnDestroy(): void {
this.widget?.destroy();
}
}
The webpackIgnore comment matters: without it the bundler tries to resolve the CDN URL at build time and fails.
Next.js — client component
"use client";
import { useEffect, useRef } from "react";
export function SupportWidget({ user }) {
const booted = useRef(false);
useEffect(() => {
if (!user || booted.current) return; // the signed-in guard
booted.current = true;
let widget;
import(
/* webpackIgnore: true */ "https://cdn.beacon.inits.dev/widget/v1/loader.js"
)
.then(({ boot }) =>
boot({
baseUrl: "https://api.beacon.inits.dev/api/v1/widget",
projectId: process.env.NEXT_PUBLIC_BEACON_PROJECT_ID,
getAssertion: async () => {
const res = await fetch("/support-identity", {
method: "POST",
credentials: "same-origin",
});
return (await res.json()).assertion;
},
}),
)
.then((instance) => {
widget = instance;
});
return () => widget?.destroy();
}, [user]);
return null;
}
The project id is public and belongs in NEXT_PUBLIC_. The signing secret never does; it is read only in the route handler above.
When it does not work
| What you see | Almost always |
|---|---|
| Nothing appears at all | your origin is not on the allowlist — Settings → Allowed origins |
| Launcher appears, requests unavailable | the assertion was refused |
| Refused, and the code looks right | the key is the secret rather than its SHA-256 digest |
| Works once, then stops | jti is not fresh — it is single-use |
| Works locally, refused in production | a different project's secret, or a clock more than a minute out |
| Refused only for some users | a missing email or org_id; both are required |
A refused assertion is answered with one generic refusal for every cause. That is deliberate — distinguishing "unknown project" from "bad signature" from "replayed jti" tells an attacker which part of a forgery to fix. It does make debugging harder, so work down the table above rather than reading the response.
Related
- Quick start — the fifteen-minute version
- Embedding the widget — every
boot()option, the events, and what happens when things fail