Skip to content

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.

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.

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.

  1. Add an endpoint that returns a token for the signed-in user.

    With the jose library:

    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 crypto module:

    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}`;
    }
  2. Return it from the widget's getToken.
    getToken: () => fetch('/api/opend-token').then((r) => r.text()),

The server checks each token:

PartRule
AlgorithmHS256 with OPEND_EMBED_SECRET. Other algorithms are refused.
audThe docs site's PUBLIC_URL, exactly, without a trailing slash.
iat, expBoth required. The lifetime, exp minus iat, must be at most 600 seconds. 30 seconds of clock skew are tolerated.
emailThe reader's email as registered in OpenD. A sub holding an email is accepted instead.
orgThe 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.