@reperio-news/signed-iframe-bridge
v0.1.1
Published
Secure parent-child iframe communication with signed JWT authentication
Downloads
210
Maintainers
Readme
signed-iframe-bridge
Secure parent-child iframe communication with signed JWT authentication.
A TypeScript library that enables a parent webpage to communicate with a child iframe via postMessage, using asymmetric JWT signatures (ES256/RS256) for authentication. Once authenticated, both sides can exchange custom messages over the same secure, origin-validated channel.
Install
npm install @reperio-news/signed-iframe-bridgeQuick Start
Parent Page
import { ParentService } from '@reperio-news/signed-iframe-bridge';
const service = new ParentService({
iframe: document.getElementById('child-iframe') as HTMLIFrameElement,
// childOrigin is automatically derived from the iframe's src attribute
onTokenRefresh: async () => {
// Fetch a signed JWT from your backend — the private key stays on the server
const res = await fetch('/api/iframe-token');
const { token } = await res.json();
return token;
},
});
await service.connect();
// Child iframe is now authenticatedChild Iframe
import { ChildService, importSPKI } from '@reperio-news/signed-iframe-bridge';
const publicKey = await importSPKI(PUBLIC_KEY_PEM, 'ES256');
const service = new ChildService({
// parentOrigin is optional — auto-locks to the first verified parent origin
publicKey,
});
// authenticate() handles everything: connect, verify, and refresh
const auth = await service.authenticate();
console.log(auth.payload.uid); // user id from JWT
console.log(auth.payload.permissions); // ['read', 'write', ...]
// Later — call authenticate() again to check validity (auto-refreshes if expired)
const freshAuth = await service.authenticate();
// Forward token to your own backend for server-side verification
const response = await fetch('/api/verify', {
headers: { Authorization: `Bearer ${freshAuth.rawToken}` },
});How It Works
Authentication Flow
- Parent creates an iframe and calls
service.connect() - Child iframe calls
service.authenticate(), which sends areadymessage to the parent - Parent receives
ready, calls theonTokenRefreshcallback, and sends the signed JWT to the child - Child verifies the JWT using the public key and resolves
authenticate()with anAuthState
Token Refresh Flow
- Child calls
await service.authenticate()again (orrequestTokenRefresh()) - If the token hasn't expired, returns the current
AuthStateimmediately - If expired (or within 2s of expiry), automatically sends a
refresh-requestto the parent - Parent calls
onTokenRefresh()and sends back the new token - Child verifies the new token and resolves the promise
Parent Child Iframe
│ │
│ ◄── ready ───────────────│ child signals it's loaded
│ │
│── auth (signed JWT) ──────────► │ parent sends initial token
│ │
│ ◄── refresh-request ─────│ child's token expired
│ │
│── refresh-response (new JWT) ──► │ parent sends fresh token
│ │
│── custom message ─────────────► │ parent.send(channel, data)
│ │
│ ◄── custom message ──────│ child.send(channel, data)
│ │Custom Messaging
Once connected, both sides can exchange arbitrary messages using send() and the custom-message event. All custom messages go through the same origin-validated, namespace-filtered channel as auth messages.
Child → Parent
// Child sends a message
service.send('user-action', { action: 'clicked', target: 'save-button' });
service.send('resize', { width: 800, height: 600 });
service.send('ping'); // data is optional// Parent listens
service.on('custom-message', ({ channel, data }) => {
switch (channel) {
case 'user-action':
console.log('User did:', data);
break;
case 'resize':
iframe.style.height = `${data.height}px`;
break;
case 'ping':
service.send('pong'); // respond back
break;
}
});Parent → Child
// Parent sends a message
service.send('theme-update', { theme: 'dark' });
service.send('config', { locale: 'en-US', features: ['beta'] });// Child listens
service.on('custom-message', ({ channel, data }) => {
if (channel === 'theme-update') {
document.body.classList.toggle('dark', data.theme === 'dark');
}
});Event Subscription
Both services emit typed events you can subscribe to for logging, monitoring, or reacting to state changes.
Child Events
const service = new ChildService({ parentOrigin, publicKey });
// Log all protocol messages received from the parent
service.on('message', (msg) => {
console.log(`[signed-iframe-bridge] received: ${msg.type}`, msg);
});
// React to initial authentication
service.on('authenticated', (auth) => {
console.log(`Authenticated as ${auth.payload?.uid}`);
});
// React to any token change (initial auth + refreshes)
service.on('token-changed', (auth) => {
console.log('Token updated, new expiry:', auth.payload?.exp);
// e.g., update headers on an API client
apiClient.setToken(auth.rawToken);
});
// Handle errors from the parent
service.on('error', ({ error, nonce }) => {
console.error('Parent error:', error.message, nonce);
});
const auth = await service.authenticate();Parent Events
const service = new ParentService({ iframe, childOrigin, onTokenRefresh });
// Log all protocol messages received from the child
service.on('message', (msg) => {
console.log(`[signed-iframe-bridge] received: ${msg.type}`, msg);
});
// Know when the child is ready
service.on('ready', () => {
console.log('Child iframe is ready');
});
// Track token deliveries
service.on('token-sent', ({ nonce }) => {
console.log('Token sent to child, nonce:', nonce);
});
// Handle refresh errors
service.on('error', ({ error, nonce }) => {
console.error('Failed to refresh token:', error.message);
});
await service.connect();Unsubscribing
on() returns an unsubscribe function:
const unsub = service.on('token-changed', (auth) => { /* ... */ });
// Later
unsub();
// Or use off() directly
const handler = (auth) => { /* ... */ };
service.on('token-changed', handler);
service.off('token-changed', handler);Backend Integration
The onTokenRefresh callback is where you integrate with your server. Here's a typical Express example:
Server (Node.js / Express)
import express from 'express';
import { createToken, importPKCS8 } from '@reperio-news/signed-iframe-bridge';
import { readFileSync } from 'fs';
const app = express();
const privateKey = await importPKCS8(readFileSync('./private-key.pem', 'utf8'), 'ES256');
app.get('/api/iframe-token', authenticateUser, async (req, res) => {
const token = await createToken(privateKey, 'ES256', {
uid: req.user.id,
user: { name: req.user.name, email: req.user.email },
permissions: req.user.permissions,
}, 3600, {
issuer: 'my-app',
audience: 'child-app',
});
res.json({ token });
});Child Backend Verification
When the child iframe forwards the raw token to its own backend:
import { verifyToken, importSPKI } from '@reperio-news/signed-iframe-bridge';
import { readFileSync } from 'fs';
const publicKey = await importSPKI(readFileSync('./public-key.pem', 'utf8'), 'ES256');
app.get('/api/data', async (req, res) => {
const token = req.headers.authorization?.replace('Bearer ', '');
if (!token) return res.status(401).json({ error: 'Missing token' });
try {
const payload = await verifyToken(token, publicKey, 'ES256', {
issuer: 'my-app',
audience: 'child-app',
});
// payload.uid, payload.permissions, etc.
res.json({ data: 'secret stuff', user: payload.uid });
} catch {
res.status(401).json({ error: 'Invalid token' });
}
});API Reference
ParentService
new ParentService(options: ParentServiceOptions)| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| iframe | HTMLIFrameElement | Yes | — | The iframe element to communicate with |
| childOrigin | string | No | Derived from iframe src | Expected origin of the child iframe. Auto-derived from the iframe's src if omitted. Use "*" to allow any origin |
| onTokenRefresh | () => Promise<string> | Yes | — | Async callback that returns a signed JWT string (fetch from your server) |
| connectTimeout | number | No | 30000 | Timeout in ms waiting for child ready |
| refreshThrottleMs | number | No | 1000 | Minimum interval in ms between refresh calls (prevents child from overwhelming your backend) |
Methods:
connect(): Promise<void>— Wait for the child to be ready, then send the initial auth token.send(channel, data?): void— Send a custom message to the child iframe.destroy(): void— Remove all event listeners and clean up.
Events:
| Event | Payload | When |
|-------|---------|------|
| message | AnyProtocolMessage | Any protocol message received from child |
| ready | void | Child iframe signalled it's ready |
| token-sent | { nonce?: string } | A token was successfully sent to the child |
| custom-message | { channel: string, data?: unknown } | A custom message was received from the child |
| error | { error: Error, nonce?: string } | Failed to handle a refresh request |
ChildService
new ChildService(options: ChildServiceOptions)| Option | Type | Required | Default | Description |
|--------|------|----------|---------|-------------|
| parentOrigin | string | No | Auto-lock | Expected origin of the parent page. If omitted, accepts any origin initially and locks onto the origin of the first verified auth token. Use "*" to allow any origin without locking |
| publicKey | CryptoKey \| KeyLike | Yes | — | Public key for JWT verification |
| algorithm | 'ES256' \| 'RS256' | No | 'ES256' | Expected signing algorithm |
| issuer | string | No | — | Expected JWT issuer (rejects mismatches) |
| audience | string | No | — | Expected JWT audience |
| connectTimeout | number | No | 30000 | Timeout in ms waiting for auth token |
| refreshTimeout | number | No | 10000 | Timeout in ms for refresh requests |
Methods:
authenticate(): Promise<AuthState>— Single entry point. Connects if needed, returns current state if valid, refreshes if expired.requestTokenRefresh(): Promise<AuthState>— Explicitly request a new token from the parent.send(channel, data?): void— Send a custom message to the parent.getRawToken(): string | null— Get the raw JWT string (for forwarding to your backend).getPayload(): SignedIframeBridgePayload | null— Get the decoded payload without re-verifying.destroy(): void— Remove all event listeners and clean up.
Events:
| Event | Payload | When |
|-------|---------|------|
| message | AnyProtocolMessage | Any protocol message received from parent |
| authenticated | AuthState | Initial authentication completed |
| token-changed | AuthState | Token changed (initial auth or refresh) |
| custom-message | { channel: string, data?: unknown } | A custom message was received from the parent |
| error | { error: Error, nonce?: string } | Error received from parent |
AuthState
interface AuthState {
valid: boolean; // Whether the token is valid and not expired
payload: SignedIframeBridgePayload | null; // Decoded JWT payload
rawToken: string | null; // Raw JWT string
}SignedIframeBridgePayload
interface SignedIframeBridgePayload extends JWTPayload {
uid: string; // User identifier
user?: Record<string, unknown>; // Arbitrary user info
permissions?: string[]; // Permission strings
}Token Helpers
Standalone functions for creating/verifying tokens (useful on your backend):
import { createToken, verifyToken, decodeToken, isTokenExpired } from '@reperio-news/signed-iframe-bridge';
// Create a signed JWT (TTL in seconds)
const token = await createToken(privateKey, 'ES256', payload, 3600, { issuer: 'my-app' });
// Verify a JWT (throws on failure)
const payload = await verifyToken(token, publicKey, 'ES256', { issuer: 'my-app' });
// Decode without verification (for quick inspection)
const decoded = decodeToken(token);
// Check if expired (includes 2s grace period by default)
const expired = isTokenExpired(token);
const expiredCustomGrace = isTokenExpired(token, 5000); // 5s graceKey Helpers
Key generation and import/export helpers (re-exported from jose):
import { generateKeyPair, importSPKI, importPKCS8, exportSPKI, exportPKCS8 } from '@reperio-news/signed-iframe-bridge';
// Generate a new key pair
const { publicKey, privateKey } = await generateKeyPair('ES256');
// Import keys from PEM strings
const pubKey = await importSPKI(pemString, 'ES256');
const privKey = await importPKCS8(pemString, 'ES256');
// Export keys to PEM strings
const pubPem = await exportSPKI(publicKey);
const privPem = await exportPKCS8(privateKey);Multiple Iframes
Create one ParentService per iframe. Each instance independently manages its own connection:
const iframes = document.querySelectorAll<HTMLIFrameElement>('.child-iframe');
const services = Array.from(iframes).map(iframe =>
new ParentService({
iframe,
childOrigin: 'https://child.example.com',
onTokenRefresh: () => generateTokenFor(iframe.id),
})
);
await Promise.all(services.map(s => s.connect()));Each instance checks event.source to ensure it only processes messages from its specific iframe.
Security
- Origin validation on every incoming message (strict match by default, or
"*"to allow any origin) - Namespace filtering — only processes messages with
ns: '@reperio-news/signed-iframe-bridge' - Asymmetric crypto — the private key stays on your server; only the public key is used client-side
- Nonce correlation for refresh request/response pairs prevents replay confusion
- Refresh throttling on the parent prevents child from overwhelming your token endpoint
- Grace period on token expiry (2s default) prevents using almost-expired tokens
- Timeouts on all async operations to prevent indefinite hanging
- Error propagation across the postMessage boundary with preserved error context
Origin Auto-Detection
Both childOrigin and parentOrigin are optional:
childOrigin(parent side) — automatically derived from the iframe'ssrcattribute if omitted.parentOrigin(child side) — if omitted, the child accepts messages from any origin initially. Once the first JWT is successfully verified, the child locks onto that origin and rejects all subsequent messages from other origins. This is the recommended default — the JWT signature acts as the trust anchor, and the locked origin provides defense-in-depth afterward.
Set either to "*" to allow any origin permanently without locking.
This is safe because JWT signature verification is the primary security mechanism — a malicious origin cannot forge a valid token without the private key.
Rate Limiting
The parent throttles refresh requests by default (1 second between calls). Adjust via refreshThrottleMs:
new ParentService({
// ...
refreshThrottleMs: 2000, // at most one refresh every 2 seconds
});Your backend onTokenRefresh endpoint should also implement its own rate limiting as a defense-in-depth measure.
Errors
All errors extend SignedIframeBridgeError and include a code property:
| Error | Code | When |
|-------|------|------|
| ConnectionTimeoutError | CONNECTION_TIMEOUT | Child didn't signal ready, or parent didn't send auth, within timeout |
| RefreshTimeoutError | REFRESH_TIMEOUT | Parent didn't respond to refresh request within timeout |
| TokenVerificationError | TOKEN_VERIFICATION_FAILED | JWT signature or claims verification failed (includes original error as cause) |
| TokenExpiredError | TOKEN_EXPIRED | Token TTL has passed |
| OriginMismatchError | ORIGIN_MISMATCH | Message received from unexpected origin |
| ProtocolError | PROTOCOL_ERROR | Malformed or unexpected protocol message |
License
MIT
