UI Integration Guide
This guide explains how to embed the Paysense Payroll UI inside an <iframe> in your own application.
Overview
The embed solution works as follows:
- You generate a short-lived encrypted authentication code server-side using a shared AES-256-GCM key.
- You construct an iframe URL containing that code as a
?code=query parameter. - The Paysense app detects the code, exchanges it for a JWT, and authenticates the user transparently.
- The payroll UI renders inside your iframe, with no login screen or redirect.
The user's credentials are never exposed to your application. The encrypted code only ever contains a username and an optional expiry.
Prerequisites
Before you begin, you will need the following from Paysense:
| Item | Description |
|---|---|
| AES-256-GCM encryption key | A 32-byte key, provided as a Base64-encoded string, specific to your tenant |
| Tenant subdomain | Your Paysense subdomain (e.g. acme.iopayroll.com) |
| Allowed origin registration | Your application's origin must be registered in the Tenant Admin portal before the browser will permit framing |
Step 1: Generate an Embed Code (Server-Side)
Embed codes must be generated on your server, never in the browser. Exposing the encryption key to client-side code would allow anyone to forge codes for arbitrary users.
The code is a Base64-encoded AES-256-GCM encrypted JSON payload:
{
"username": "user@example.com",
"expiry": "2026-01-01T12:00:30Z" // optional
}
Payload Fields
| Field | Type | Required | Description |
|---|---|---|---|
username | string | Yes | The email address of the user to authenticate |
expiry | string (ISO 8601 UTC) | Recommended | UTC timestamp after which the code is rejected. Recommended: 60 seconds from now |
Encryption Spec
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM |
| Key size | 32 bytes (256-bit), Base64-encoded |
| Nonce size | 12 bytes (96-bit), randomly generated per code |
| Tag size | 16 bytes (128-bit) |
| Output binary layout | [Nonce (12 bytes)][Ciphertext][Auth Tag (16 bytes)] |
| Output encoding | Standard Base64 |
Code Samples
- C# / .NET
- Node.js / TypeScript
- Python
- Java
using System;
using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
public static string GenerateEmbedCode(string base64Key, string username, TimeSpan? validity = null)
{
var payload = new
{
username,
expiry = validity.HasValue
? DateTime.UtcNow.Add(validity.Value).ToString("o")
: (string?)null
};
var json = JsonSerializer.Serialize(payload,
new JsonSerializerOptions { DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull });
var key = Convert.FromBase64String(base64Key);
var plaintext = Encoding.UTF8.GetBytes(json);
var nonce = new byte[12];
RandomNumberGenerator.Fill(nonce);
var ciphertext = new byte[plaintext.Length];
var tag = new byte[16];
using var aesGcm = new AesGcm(key, 16);
aesGcm.Encrypt(nonce, plaintext, ciphertext, tag);
var combined = new byte[nonce.Length + ciphertext.Length + tag.Length];
Buffer.BlockCopy(nonce, 0, combined, 0, nonce.Length);
Buffer.BlockCopy(ciphertext, 0, combined, nonce.Length, ciphertext.Length);
Buffer.BlockCopy(tag, 0, combined, nonce.Length + ciphertext.Length, tag.Length);
return Convert.ToBase64String(combined);
}
// Usage
var code = GenerateEmbedCode(
base64Key: Environment.GetEnvironmentVariable("PAYSENSE_EMBED_KEY")!,
username: "user@example.com",
validity: TimeSpan.FromSeconds(60)
);
import { createCipheriv, randomBytes } from "crypto";
export const generateEmbedCode = (
base64Key: string,
username: string,
validitySeconds = 60
): string => {
const payload = JSON.stringify({
username,
expiry: new Date(Date.now() + validitySeconds * 1000).toISOString(),
});
const key = Buffer.from(base64Key, "base64");
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce);
const ciphertext = Buffer.concat([
cipher.update(payload, "utf8"),
cipher.final(),
]);
const tag = cipher.getAuthTag(); // 16 bytes
const combined = Buffer.concat([nonce, ciphertext, tag]);
return combined.toString("base64");
};
// Usage
const code = generateEmbedCode(
process.env.PAYSENSE_EMBED_KEY!,
"user@example.com"
);
import base64
import json
import os
from datetime import datetime, timezone, timedelta
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
def generate_embed_code(
base64_key: str,
username: str,
validity_seconds: int = 60
) -> str:
key = base64.b64decode(base64_key)
nonce = os.urandom(12)
expiry = (datetime.now(timezone.utc) + timedelta(seconds=validity_seconds)).strftime(
"%Y-%m-%dT%H:%M:%SZ"
)
payload = json.dumps({"username": username, "expiry": expiry}).encode("utf-8")
aesgcm = AESGCM(key)
# cryptography library appends the 16-byte tag to the ciphertext
encrypted = aesgcm.encrypt(nonce, payload, None)
ciphertext = encrypted[:-16]
tag = encrypted[-16:]
combined = nonce + ciphertext + tag
return base64.b64encode(combined).decode("utf-8")
# Usage
code = generate_embed_code(
base64_key=os.environ["PAYSENSE_EMBED_KEY"],
username="user@example.com",
)
import javax.crypto.Cipher;
import javax.crypto.spec.GCMParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.GeneralSecurityException;
import java.security.SecureRandom;
import java.time.Instant;
import java.util.Base64;
import org.json.JSONObject;
public String generateEmbedCode(String base64Key, String username, int validitySeconds)
throws GeneralSecurityException {
byte[] key = Base64.getDecoder().decode(base64Key);
byte[] nonce = new byte[12];
new SecureRandom().nextBytes(nonce);
String expiry = Instant.now().plusSeconds(validitySeconds).toString();
String payload = new JSONObject()
.put("username", username)
.put("expiry", expiry)
.toString();
byte[] plaintext = payload.getBytes(StandardCharsets.UTF_8);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
GCMParameterSpec spec = new GCMParameterSpec(128, nonce); // 128-bit tag
cipher.init(Cipher.ENCRYPT_MODE, new SecretKeySpec(key, "AES"), spec);
byte[] encrypted = cipher.doFinal(plaintext);
// Java appends the tag to the ciphertext: encrypted = ciphertext + tag
byte[] combined = new byte[12 + encrypted.length];
System.arraycopy(nonce, 0, combined, 0, 12);
System.arraycopy(encrypted, 0, combined, 12, encrypted.length);
return Base64.getEncoder().encodeToString(combined);
}
Step 2: Construct the iframe URL
Append the generated code to any Paysense route as a ?code= query parameter:
https://<your-tenant>.iopayroll.com/<route>?code=<generated-code>
Available Routes
| Route | Description |
|---|---|
/business/{businessId}/dashboard | Business dashboard |
/business/{businessId}/employees | Employee list |
/business/{businessId}/employees/{employeeId}/details | Employee details |
/business/{businessId}/employees/{employeeId}/bank | Employee bank details |
/business/{businessId}/employees/{employeeId}/tax | Employee tax |
/business/{businessId}/employees/{employeeId}/super | Employee superannuation |
/business/{businessId}/paycycles | Pay cycles |
/business/{businessId}/payruns | Pay runs |
/business/{businessId}/super/contributions | Super contributions |
/business/{businessId}/stp | Single Touch Payroll |
/business/{businessId}/details | Business details |
/business/{businessId}/reports/audit-logs | Audit log reports |
/business/{businessId}/reports/webhook-logs | Webhook log reports |
Replace {businessId} and {employeeId} with the relevant identifiers for the authenticated user's business context.
The table above lists the most commonly embedded routes. Any authenticated route in the Paysense app accepts the ?code= query parameter, so you are not limited to the routes listed here.
Step 3: Render the iframe
<iframe
src="https://acme.iopayroll.com/business/42/employees?code=..."
style="width: 100%; height: 800px; border: none;"
title="Payroll"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
referrerpolicy="strict-origin-when-cross-origin"
></iframe>
sandbox Attribute
The following sandbox tokens are required:
| Token | Reason |
|---|---|
allow-scripts | Required for the React application to run |
allow-same-origin | Required for sessionStorage access - tokens and auth state are stored there; without this the sandboxed frame is treated as a null origin and cannot access storage |
allow-forms | Included defensively. All forms use React Hook Form with event.preventDefault(), so no form navigates to an action URL, but omitting this can cause unexpected behaviour in some browsers |
allow-popups | Required for window.open() calls - specifically the Beam SuperStream OAuth hosted UI and the payslip print window |
allow-downloads | Required for file downloads. Payment files and payslip PDFs are downloaded via a blob URL assigned to a synthetic <a download> element; browsers block this in a sandboxed iframe without this token |
Do not add allow-top-navigation or allow-top-navigation-by-user-activation unless your integration specifically requires it, as these tokens allow the embedded app to redirect the parent frame.
UI Chrome Parameters
By default, when a ?code= parameter is present, both the side navigation and top app bar are hidden. You can override this behaviour:
| Parameter | Values | Default (with ?code=) |
|---|---|---|
showSideNav | true / false | false |
showAppBar | true / false | false |
Example with navigation visible:
?code=<encrypted>&showSideNav=true&showAppBar=true
Step 4: Generate a Fresh Code Per Load
Embed codes should be generated on-demand, just before the iframe is rendered. Do not cache or reuse codes.
type Props = {
businessId: string | number;
route: string;
username: string;
};
// React example - generate a fresh code server-side before rendering
const PayrollEmbed = ({ businessId, route, username }: Props) => {
const [iframeSrc, setIframeSrc] = useState<string | null>(null);
useEffect(() => {
// Call your backend to generate a fresh code
fetch("/api/payroll-embed/code", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ username, businessId }),
})
.then((res) => res.json())
.then(({ code }) => {
const params = new URLSearchParams({ code });
setIframeSrc(
`https://acme.iopayroll.com/business/${businessId}/${route}?${params}`
);
});
}, [businessId, route, username]);
if (!iframeSrc) return <div>Loading...</div>;
return (
<iframe
src={iframeSrc}
style={{ width: "100%", height: "800px", border: "none" }}
title="Payroll"
sandbox="allow-scripts allow-same-origin allow-forms allow-popups allow-downloads"
/>
);
};
Your backend endpoint should:
- Authenticate the request (verify the requesting user is authorised).
- Generate the encrypted code using the shared key.
- Return the code with a short TTL (e.g. 60 seconds).
Step 5: Programmatic Navigation (postMessage)
Once the embed auth flow has completed inside the iframe, you can navigate the embedded app to a new route from the parent frame using postMessage.
const navigateEmbed = (iframeEl: HTMLIFrameElement, route: string, targetOrigin: string): void => {
iframeEl.contentWindow?.postMessage(
{ action: "navigate", payload: { route } },
targetOrigin
);
};
// Example - navigate to pay runs using a ref
const iframeRef = useRef<HTMLIFrameElement>(null);
// Attach the ref to your iframe:
// <iframe ref={iframeRef} ... />
if (iframeRef.current) {
navigateEmbed(
iframeRef.current,
"/business/42/payruns",
"https://acme.iopayroll.com"
);
}
Supported Actions
| Action | Payload | Description |
|---|---|---|
navigate | { route: string } | Navigate to a new route within the embedded app. The route must start with /. |
Timing
The embedded app registers the message event listener at mount, but will silently drop any postMessage sent before the embed code exchange has completed. The origin allowlist is populated as part of that exchange and is empty until it finishes.
Important: The embedded app does not currently emit a readiness signal to the parent frame. There is no event you can listen for to know when auth has completed. You must account for this in your integration, for example by delaying navigation commands until you are confident the iframe has had enough time to complete the auth flow, or by re-sending the command if the expected navigation does not occur.
Security
- Allowed embed origins are returned by the Paysense API as part of the embed code exchange and stored in the iframe's
sessionStorage. On every inboundpostMessage, the embedded app reads those stored origins and validatesevent.originagainst them. Any message from an origin not in that list is silently ignored. - Always pass the exact Paysense tenant origin as
targetOrigininpostMessage. Never use"*".
Partner Configuration Checklist
Before going live, ensure the following is in place on your side.
Security
- The AES-256-GCM encryption key is stored securely (environment variable, secrets manager, Azure Key Vault). Never store it in source code or client bundles.
- Embed codes are generated server-side only.
- All codes include an
expiryof ≤ 60 seconds. - The endpoint that generates codes is authenticated. Only your application can call it, not end users directly.
Content Security Policy
If your application sets a Content-Security-Policy header, you must allow framing the Paysense origin:
frame-src https://<your-tenant>.iopayroll.com;
iframe sandbox Attribute
- Ensure
allow-scriptsandallow-same-originare always present, as the app cannot function without them.
Cookie / Storage Policies
- The Paysense app uses
sessionStoragein embed mode. Browsers that block third-party storage (e.g. Safari ITP) may require the user to explicitly permit storage access. - Test in Safari and Firefox private browsing modes before going live.
HTTPS
- Your application must be served over HTTPS. Paysense enforces
upgrade-insecure-requestsin production and theframe-ancestorsCSP directive will not match plainhttp://origins.
Error Handling
The Paysense app renders an /unauthorized page when embed authentication fails.
| Cause | Symptom | Resolution |
|---|---|---|
Expired code (expiry in the past) | 404 from /api/public/embed/code | Generate a fresh code just before rendering the iframe |
| Wrong encryption key | 404 from /api/public/embed/code | Verify the key matches the tenant configuration |
| Tampered or malformed ciphertext | 404 from /api/public/embed/code | Ensure the Base64 output is not URL-encoded or truncated |
Origin not in AllowedEmbedOrigins | Browser blocks iframe (CSP violation) | Add the partner origin to Security:AllowedEmbedOrigins |
X-Frame-Options blocking | Browser blocks iframe | Ensure AllowedEmbedOrigins is set (suppresses the SAMEORIGIN header) |
| User does not exist in Keycloak | 401/404 from token exchange | Provision the user in Keycloak for the tenant |
| Third-party storage blocked | App cannot store tokens | Test in browsers with strict cookie policies; consider Storage Access API |
Security Considerations
- Codes are one-time-use from a trust perspective: the same code can technically be replayed until its
expiry. Always use short expiry values (≤ 60 seconds) and generate a new code for each iframe render. - AES-256-GCM provides authentication: tampered ciphertexts are rejected during decryption. You cannot forge a valid code without the key.
- Keys are per-tenant: a code encrypted with tenant A's key cannot be accepted by tenant B.
postMessageis origin-validated: the embedded app acceptspostMessagecommands only from origins listed inSecurity:AllowedEmbedOrigins. Messages from unlisted origins are silently ignored. Always specify the exact tenant origin astargetOriginwhen callingpostMessage; never use"*".- Token storage is
sessionStorage: embed tokens do not persist across browser sessions or leak to non-embed browser contexts.