{
"$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"
}