Sign embed tokens in your backend
Your app already knows who is signed in. Its backend proves that to OpenD with a short-lived token signed with a shared secret.
Before you begin
You need the deployment's embed secret, OPEND_EMBED_SECRET, in your backend's
configuration. Treat it like a password: anyone holding it can sign in as any registered reader. Never
send it to the browser.
About this task
The token only says who the user is. What they may read still comes from OpenD: their organization's entitlements. A host app therefore cannot grant more than OpenD allows.
-
Add an endpoint that returns a token for the signed-in user.
With the
joselibrary:import { SignJWT } from 'jose'; const secret = new TextEncoder().encode(process.env.OPEND_EMBED_SECRET); const DOCS_URL = 'https://docs.example.com'; // the docs site's PUBLIC_URL, no trailing slash export function opendToken(user) { return new SignJWT({ email: user.email, org: user.opendOrg }) .setProtectedHeader({ alg: 'HS256' }) .setAudience(DOCS_URL) .setIssuedAt() .setExpirationTime('5m') .sign(secret); } // Express, for example. Only for signed-in users, and never cached. app.get('/api/opend-token', requireSignIn, async (req, res) => { res.set('cache-control', 'no-store').type('text/plain').send(await opendToken(req.user)); });Or with nothing but Node's
cryptomodule:import { createHmac } from 'node:crypto'; export function opendToken({ email, org }) { const b64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url'); const now = Math.floor(Date.now() / 1000); const body = `${b64({ alg: 'HS256', typ: 'JWT' })}.${b64({ email, org, aud: DOCS_URL, iat: now, exp: now + 300 })}`; const signature = createHmac('sha256', process.env.OPEND_EMBED_SECRET).update(body).digest('base64url'); return `${body}.${signature}`; } -
Return it from the widget's
getToken.getToken: () => fetch('/api/opend-token').then((r) => r.text()),
Results
The server checks each token:
| Part | Rule |
|---|---|
| Algorithm | HS256 with OPEND_EMBED_SECRET. Other algorithms are refused. |
aud | The docs site's PUBLIC_URL, exactly, without a trailing slash. |
iat, exp | Both required. The lifetime, exp minus iat, must be at most 600 seconds. 30 seconds of clock skew are tolerated. |
email | The reader's email as registered in OpenD. A sub holding an email is accepted instead. |
org | The slug of the reader's organization. It must match the organization registered for that email. |
An invalid or expired token gets 401. A valid token for an email that is not registered, or for the
wrong organization, gets 403. A good token is exchanged for an opend_embed session
cookie that lasts 60 minutes, after which the panel asks for a new token.