logo
Development
Search
Push Handoff (Identity Handoff / Push Handoff)

Push Handoff (Identity Handoff / Push Handoff)

Scenario: the user clicks the developer's extension app on the Workspace → Extensions page, and the platform pushes the user's identity to the target extension app's landing page as ?wsa=<JWT>. The developer consumes it on the landing page, verifies it on the backend, and then exchanges it for their own session.

In this mode the developer does not need to call the platform's signing endpoint — that is called by the workspace frontend when the user clicks. The developer is only responsible for "receiving and verifying."

End-to-End Sequence

loading...
sequenceDiagram
    autonumber
    actor User as User accessing the extension app
    participant GB as GPTBots platform
    participant FE as Extension app - landing page
    participant BE as Extension app - backend

    User->>GB: Click the app icon (POST sign-token)
    GB->>GB: Verify the clicker is a member of this workspace
    GB->>GB: Sign wsa with the secret<br/>(5-minute JWT, aud=extension app host)
    GB->>FE: Open app_home_url?wsa=<JWT>

    Note over FE: consumeHandoff()<br/>read ?wsa=, POST to the extension app backend
    FE->>BE: POST /session/exchange { wsa }
    BE->>BE: verifyWsa() → identity
    BE->>BE: Establish own session
    BE-->>FE: Return session (Set-Cookie)

    Note over FE: history.replaceState strips ?wsa=

Rules for how the platform generates the redirect URL (developers do not need to implement this):

  • From the registration info, the platform performs an exact match on app_home_url to locate the target extension app (any URL not on file is refused signing, to prevent identity leakage).
  • It verifies that the clicker is indeed a member of this workspace (workspace_id).
  • If app_home_url already contains a query, the wsa is appended with &; the wsa value is already URL-encoded, so you do not need to decode it manually after reading it.

Frontend: Consume the wsa on the Landing Page

import { consumeHandoff } from '@gptbots/workspace-extension-sdk'; try { const identity = await consumeHandoff({ exchangeUrl: '/session/exchange', // your own backend verification endpoint // search: location.search, // reads location.search by default // paramName: 'wsa', // the default parameter name is wsa // strip: true, // by default, strips wsa from the URL on success }); bootYourApp(identity); } catch (e) { // no wsa (user accessed directly), or backend verification failed showLoginOrError(e); }
                      
                      import { consumeHandoff } from '@gptbots/workspace-extension-sdk';

try {
  const identity = await consumeHandoff({
    exchangeUrl: '/session/exchange', // your own backend verification endpoint
    // search:   location.search,     // reads location.search by default
    // paramName: 'wsa',              // the default parameter name is wsa
    // strip:     true,              // by default, strips wsa from the URL on success
  });
  bootYourApp(identity);
} catch (e) {
  // no wsa (user accessed directly), or backend verification failed
  showLoginOrError(e);
}

                    
This code block in the floating window

consumeHandoff does four things: ① read ?wsa= ② POST { wsa } to exchangeUrlon success, history.replaceState to strip ?wsa= ④ return the identity sent back by the backend.

When it is stripped: the token is stripped from the URL only after a successful exchange, so that a single transient failure can be retried by refreshing. This is a 5-minute one-time token; if the developer wants it stripped immediately even on failure, call stripHandoffToken() manually in the catch block.

Read Identity Only, Without a Session (receive-only)

import { readHandoffToken, stripHandoffToken } from '@gptbots/workspace-extension-sdk'; const token = readHandoffToken(); // pure function, returns the raw JWT string or null if (token) { // still recommended to send the token to your backend verifyWsa before trusting its contents (do not parse the JWT on the frontend as a trusted identity) await fetch('/telemetry/visit', { method: 'POST', body: JSON.stringify({ wsa: token }) }); } stripHandoffToken(); // strip wsa from the URL regardless
                      
                      import { readHandoffToken, stripHandoffToken } from '@gptbots/workspace-extension-sdk';

const token = readHandoffToken();   // pure function, returns the raw JWT string or null
if (token) {
  // still recommended to send the token to your backend verifyWsa before trusting its contents (do not parse the JWT on the frontend as a trusted identity)
  await fetch('/telemetry/visit', { method: 'POST', body: JSON.stringify({ wsa: token }) });
}
stripHandoffToken();                // strip wsa from the URL regardless

                    
This code block in the floating window

⚠️ Do not decode the JWT on the frontend and treat it as a trusted identity. A JWT's signature can only be verified with the secret, and the secret lives only on the backend. Frontend parsing may be used only for "non-security" display placeholders; any authorization decision must be based on the result of the backend's verifyWsa.


Backend: Verify the wsa

import { verifyWsa, WsaVerificationError } from '@gptbots/workspace-extension-verify'; app.post('/session/exchange', (req, res) => { try { const identity = verifyWsa(req.body.wsa, { secret: process.env.EXTENSION_APP_SECRET, // Tier2 per-app secret or Tier1 shared secret audience: 'app.example.com', // extension app host, must equal aud // issuer: 'gptbots-workspace', // default // leewaySeconds: 30, // clock drift tolerance, default 30s // algorithms: ['HS256'], // default }); // Multi-tenant isolation: attribute the request to identity.workspaceId const sid = createSession(identity); res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' }); res.json(identity); } catch (e) { const code = e instanceof WsaVerificationError ? e.code : 'Error'; res.status(401).json({ code }); } });
                      
                      import { verifyWsa, WsaVerificationError } from '@gptbots/workspace-extension-verify';

app.post('/session/exchange', (req, res) => {
  try {
    const identity = verifyWsa(req.body.wsa, {
      secret: process.env.EXTENSION_APP_SECRET, // Tier2 per-app secret or Tier1 shared secret
      audience: 'app.example.com',              // extension app host, must equal aud
      // issuer: 'gptbots-workspace',          // default
      // leewaySeconds: 30,                    // clock drift tolerance, default 30s
      // algorithms: ['HS256'],                // default
    });

    // Multi-tenant isolation: attribute the request to identity.workspaceId
    const sid = createSession(identity);
    res.cookie('sid', sid, { httpOnly: true, secure: true, sameSite: 'lax' });
    res.json(identity);
  } catch (e) {
    const code = e instanceof WsaVerificationError ? e.code : 'Error';
    res.status(401).json({ code });
  }
});

                    
This code block in the floating window

Without the SDK

The wsa is a standard HS256 JWT, and any language's JWT library can verify it. See the Java / Node / Python examples in 05 - Token Verification and Security §3. Whether or not you use the SDK, all four of signature / iss / aud / exp must be verified.

Registering the Extension App (Obtaining the Secret)

As the workspace OWNER/ADMIN: Workspace → Space Management → Extension Apps → Add, and fill in:

Field Description
App name The display name; keep it ≤ 12 Chinese characters to avoid truncation
App icon A square icon, recommended ≥ 128×128
App entry URL Your app_home_url, used as the unique key; matched strictly as a full string when signing
Auth mode Choose workspace_account (identity passing required)

After submitting, the App Secret is displayed in plaintext one time only — copy and save it immediately (after the dialog is closed, it can only be rotated).

Landing Page Checklist

  • After receiving ?wsa=, POST it to the extension app backend service first for verification, then trust the contents
  • On successful verification, immediately history.replaceState to strip the wsa (consumeHandoff does this by default)
  • Exchange it for your own session; subsequent XHR/fetch/img no longer pass through the wsa
  • Handle the "user accessed directly, no wsa" branch (guide to login or go anonymous)
  • On verification failure, give a readable message based on WsaVerificationError.code

Security details and multi-language verification: Token Verification and Security. To switch to an "in-app login button": Pull Login.