{
  "$type": "site.standard.document",
  "bskyPostRef": {
    "cid": "bafyreiensx6sidun5cf3uuxlvahnaxqz2abnqa4jqzylrdt2dfki6q5xxe",
    "uri": "at://did:plc:25rdn5elo5izoxrmtis34zuk/app.bsky.feed.post/3mpdbuomt3ik2"
  },
  "coverImage": {
    "$type": "blob",
    "ref": {
      "$link": "bafkreifi5gktw2eeebab62gy2vevqf56kb35ggrp3bf5ip4detbivjps2e"
    },
    "mimeType": "image/webp",
    "size": 77164
  },
  "path": "/androve2k/signed-token-between-two-pwas-hmac-sha256-with-no-backend-3jod",
  "publishedAt": "2026-06-28T05:12:28.000Z",
  "site": "https://dev.to",
  "tags": [
    "javascript",
    "webdev",
    "firebase",
    "security",
    "roversia.it"
  ],
  "textContent": "How I passed the logged-in user’s identity from one PWA to another running on a completely separate Firebase project — without writing a single line of backend code, using only the browser’s Web Crypto API and a signed URL.\n\n##  The problem: two Firebase projects, no shared state\n\nPanelControl is an internal PWA that manages operators, calendar and chat for a commercial team. From the panel, team members need to open a separate site — let’s call it _Orders_ — running on a completely different Firebase project.\n\nThe ask was simple: when an operator clicks “Onboarding”, the destination site must **know who they are** without a second login. The problem is that the two apps share neither database nor Firebase authentication.\n\nThere were three options:\n\nOption | How it works | Drawback\n---|---|---\nA — Shared Firebase | Writes a token to a shared RTDB node | The two projects have separate databases\nB — HMAC signed URL | Generates a link with token in `?auth=` param | Secret lives in the client (internal tool, acceptable)\nC — postMessage | iframe/popup communication | Requires same domain or controlled popup opening\n\nWith separate databases and a direct link as the entry point, **option B is the right call**. No extra infrastructure, works immediately.\n\n##  How HMAC-SHA256 works in the browser\n\nHMAC (Hash-based Message Authentication Code) produces a cryptographic signature of a message using a shared secret key. Without that key, the signature cannot be reproduced — so the receiver knows the sender possesses it.\n\nThe Web Crypto API is available in all modern browsers, requires no libraries, and works natively with `ArrayBuffer`. The flow is:\n\n\n\n    // SENDER SIDE\n    payload  = { user: \"Alice\", dept: \"Sales\", ts: Date.now() }\n    token    = base64url( HMAC-SHA256(JSON.stringify(payload), SECRET) )\n    url      = \"https://orders-app.netlify.app/?auth=\" + token + \".\" + base64url(payload)\n\n    // RECEIVER SIDE\n    [sig, data] = url.searchParam(\"auth\").split(\".\")\n    expectedSig = HMAC-SHA256(base64url_decode(data), SECRET)\n    if sig !== expectedSig → invalid token, access denied\n    if Date.now() - payload.ts > 5 min → token expired\n    otherwise → window.panelUser = payload.user ✓\n\n\n##  The implementation: sender side\n\nThe “Onboarding” link became a button that calls an async function. It reads the current user from app state, builds the payload, signs it and opens the link.\n\n\n\n    // Shared secret — must be identical in the receiver\n    const SHARED_SECRET = 'YourSharedSecret123!';\n    const DEST_URL      = 'https://orders-app.netlify.app/';\n\n    // Helper: ArrayBuffer → base64url\n    function buf2b64(buffer) {\n      return btoa(String.fromCharCode(...new Uint8Array(buffer)))\n        .replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n    }\n\n    async function openOnboardingWithToken() {\n      const user = state.currentUser?.name || 'Unknown';\n\n      const payload = {\n        user,\n        dept: state.currentUser?.dept || '',\n        ts: Date.now()\n      };\n\n      const payloadB64 = buf2b64(\n        new TextEncoder().encode(JSON.stringify(payload))\n      );\n\n      // Import HMAC key\n      const key = await crypto.subtle.importKey(\n        'raw',\n        new TextEncoder().encode(SHARED_SECRET),\n        { name: 'HMAC', hash: 'SHA-256' },\n        false,\n        ['sign']\n      );\n\n      // Sign the payload\n      const sigBuffer = await crypto.subtle.sign(\n        'HMAC',\n        key,\n        new TextEncoder().encode(payloadB64)\n      );\n      const sig = buf2b64(sigBuffer);\n\n      const token = `${sig}.${payloadB64}`;\n      window.open(`${DEST_URL}?auth=${token}`, '_blank');\n    }\n\n\n> **Note on token format:** I use `sig.payload` separated by a dot — like JWTs, but without a header. The receiver splits on `.`, recomputes the signature over the payload and compares.\n\n##  Receiver side: verification and URL cleanup\n\nIn the Orders site, a script in the `<head>` runs before any other code. It does three things: verifies the signature, checks expiry (5 minutes), and strips `?auth=` from the URL so no token remains visible in the browser bar.\n\n\n\n    const SHARED_SECRET = 'YourSharedSecret123!'; // must be identical\n    const TOKEN_TTL_MS  = 5 * 60 * 1000;          // 5 minutes\n\n    (async () => {\n      const params = new URLSearchParams(location.search);\n      const auth   = params.get('auth');\n      if (!auth) return;\n\n      // Clean the URL immediately\n      history.replaceState({}, '', location.pathname);\n\n      const [sig, payloadB64] = auth.split('.');\n      if (!sig || !payloadB64) return;\n\n      // Import key in verify mode\n      const key = await crypto.subtle.importKey(\n        'raw',\n        new TextEncoder().encode(SHARED_SECRET),\n        { name: 'HMAC', hash: 'SHA-256' },\n        false,\n        ['verify']\n      );\n\n      // Decode signature from base64url to ArrayBuffer\n      const sigBytes = Uint8Array.from(\n        atob(sig.replace(/-/g, '+').replace(/_/g, '/')),\n        c => c.charCodeAt(0)\n      );\n\n      const valid = await crypto.subtle.verify(\n        'HMAC', key, sigBytes,\n        new TextEncoder().encode(payloadB64)\n      );\n\n      if (!valid) { console.warn('[auth] invalid signature'); return; }\n\n      // Decode payload\n      const payload = JSON.parse(\n        decodeURIComponent(escape(atob(\n          payloadB64.replace(/-/g, '+').replace(/_/g, '/')\n        )))\n      );\n\n      // Check expiry\n      if (Date.now() - payload.ts > TOKEN_TTL_MS) {\n        console.warn('[auth] token expired'); return;\n      }\n\n      // All good — expose user to the rest of the site\n      window.panelUser = payload.user;\n      sessionStorage.setItem('panelUser', payload.user);\n      document.dispatchEvent(new CustomEvent('panelUserReady', { detail: payload }));\n      console.log('[auth] ✓', payload.user);\n    })();\n\n\n##  Displaying the user in the header\n\nOnce `window.panelUser` is available, the Orders site shows it with an amber badge in the header — giving the operator visual confirmation that their identity was transmitted correctly.\n\n\n\n    // After the verification script runs\n    document.addEventListener('panelUserReady', (e) => {\n      const badge = document.getElementById('user-badge');\n      if (badge) badge.textContent = '👤 ' + e.detail.user;\n    });\n\n\n\n    <!-- Header HTML -->\n    <span id=\"user-badge\" style=\"\n      background: rgba(251,191,36,.15);\n      border: 1px solid rgba(251,191,36,.3);\n      color: #fbbf24; border-radius: 20px;\n      padding: .2rem .75rem; font-size: .8rem;\n    \"></span>\n\n\n##  Security considerations\n\nThis solution has an explicit limitation: **the secret lives in the client code** of both sites. Anyone who opens DevTools can see it. For an internal business tool this is acceptable — it’s not a public site and the token only contains the operator’s name, no sensitive data.\n\n> ⚠️ If you change the shared secret, **all previously generated tokens stop working** immediately — any link older than 5 minutes was already expired anyway, but good to be aware of.\n\nFor a public app or one handling sensitive data, you’d use a server-side signed token (e.g. Firebase Custom Token or a Node.js endpoint), keeping the secret out of the client entirely. But for this context, the client-only solution works perfectly.\n\n##  The result\n\nThe operator clicks the Onboarding button in PanelControl. The browser opens the Orders site with `?auth=TOKEN` in the URL. The script verifies the signature in under a millisecond, cleans the URL, and the user badge appears in the header. The user is then available via `sessionStorage.getItem('panelUser')` throughout the rest of the site’s code.\n\nNo intermediate database, no extra backend, no external dependencies. Just native browser cryptography and a shared secret.\n\n_Originally published on roversia.it_",
  "title": "Signed token between two PWAs: HMAC-SHA256 with no backend"
}