ZTG Web Element
The ZTG Web Element lets your users enter API keys through a secure input field embedded in your app. The secret never touches your JavaScript — it goes directly from the browser to Layr8’s secure infrastructure, and your application only receives an opaque tokenId reference.
Think of it like Stripe Elements for API keys: your users type sensitive values into a field that looks native to your app, but the input is isolated from your code.
Prerequisites
Before integrating the web element, you need:
- A running ZTG HTTP Proxy — deployed in your infrastructure, pointing at the target API. See ZTG Integration Guide for deployment instructions (VM or Kubernetes).
- Vault-proxy URL (
apiOrigin) —https://ztg-vault-proxy.layr8.cloud(same for all customers). The SDK sends secrets to this endpoint for tokenization. - A backend endpoint that creates tokenize sessions by calling the http-proxy’s
POST /ztg/tokenize-session.
How It Works
Your App (your-app.com) Layr8 (js.ztg.layr8.cloud)+----------------------------+ +-----------------------------+| | | || <SecretField /> | iframe| <input type="password"> || +----------------------+ | ----> | || | ******************** | | | Secret stays here || +----------------------+ | | || | | || ztg.tokenize(getSession) | | Encrypts & stores secret || --------------------------|------>| || | | || Promise<{ tokenId }> | | Returns reference only || <-------------------------|-------| |+----------------------------+ +-----------------------------+- Your frontend renders a
SecretField— a secure input hosted by Layr8 - The user types their API key
- Your code calls
tokenize()with agetSessioncallback - The secure iframe generates security keys, the SDK calls your callback with the public keys, and your backend creates a session token bound to those keys
- The secret is encrypted and stored directly by Layr8
- Your code receives a
tokenId— an opaque reference, not the secret
When your application later needs the API key, it sends requests through the ZTG proxy with ${tokenId} placeholders in headers. Layr8 resolves the placeholder to the real secret and forwards the request to the target API. Your application never handles the plaintext.
Packages
| Package | Purpose | Framework |
|---|---|---|
@layr8/ztg-js | Core SDK — secure field, tokenization | Vanilla JS |
@layr8/ztg-react | React bindings — <ZTGProvider>, <SecretField>, useZTG() | React 18+ |
The npm packages are thin loaders. The SDK loads at runtime from Layr8’s CDN — identical to how @stripe/stripe-js works. Security patches ship instantly without waiting for npm update.
Quick Start
Script Tag (No Build Step)
<script src="https://js.ztg.layr8.cloud/v1/ztg.js"></script><div id="secret-field"></div><button onclick="save()">Save Key</button>
<script> let ztg;
// Callback that creates sessions with security keys bound to the browser async function getSession(publicKeys) { const res = await fetch("/api/ztg/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ publicKeys }), }); return (await res.json()).sessionToken; }
async function init() { ztg = ZTG.create("https://ztg-vault-proxy.layr8.cloud"); ztg.createField("#secret-field", { placeholder: "Enter API key", }); }
async function save() { const { tokenId } = await ztg.tokenize(getSession); // Send tokenId to your backend — it's safe, it's not the secret await fetch("/api/save-key", { method: "POST", body: JSON.stringify({ tokenId }), }); }
init();</script>React
npm install @layr8/ztg-js @layr8/ztg-reactimport { ZTGProvider, SecretField, useZTG } from "@layr8/ztg-react";import type { GetSession } from "@layr8/ztg-react";
// Callback that creates sessions with security keys bound to the browserconst getSession: GetSession = async (publicKeys) => { const res = await fetch("/api/ztg/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ publicKeys }), }); return (await res.json()).sessionToken;};
function App() { return ( <ZTGProvider apiOrigin="https://ztg-vault-proxy.layr8.cloud"> <KeyOnboarding /> </ZTGProvider> );}
function KeyOnboarding() { const ztg = useZTG();
const handleSubmit = async () => { const { tokenId } = await ztg.tokenize(getSession); // Store tokenId in your database — never the secret };
return ( <form onSubmit={handleSubmit}> <SecretField placeholder="Enter API key" /> <button type="submit">Save</button> </form> );}Backend Setup
Before the frontend can tokenize, your backend creates a session token. Session tokens are short-lived (5 minutes) and scoped to a specific customer. The SDK calls your session endpoint with security keys, which your backend forwards to the http-proxy to bind the session to the originating browser.
POST /ztg/tokenize-sessionContent-Type: application/json
{ "salted_customer_id": "c8f3e2a1b4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1", "public_keys": ["dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"]}The public_keys field is required — it contains security keys that bind the session to the originating browser. Each secure iframe generates these keys internally, and the SDK passes them to your callback automatically.
Response:
{ "session_token": "eyJhbGciOiJFZERTQSIs..."}Your backend returns the session_token to the SDK via the getSession callback.
The salted_customer_id is a SHA-256 hash of a salt plus your internal customer identifier. Layr8 never sees your raw customer IDs.
Generating the Salted Customer ID
The salt is a secret string that you generate and store. It prevents Layr8 from correlating customer IDs across organizations and protects against rainbow table attacks.
Best practices:
- Generate a random salt (at least 32 bytes) once and store it as an environment variable or secret
- Use the same salt for all customers in your organization — consistency is what makes token IDs deterministic
- Never lose the salt — if you change it, all existing
salted_customer_idvalues change and stored tokens become inaccessible under the new IDs - The salt is not sent to Layr8 — only the resulting hash
// Generated once with: openssl rand -hex 32// Store as an environment variable — do not hardcode or regenerateconst SALT = process.env.ZTG_CUSTOMER_SALT;
// Hash a customer IDfunction saltCustomerId(customerId) { return crypto.createHash("sha256").update(SALT + customerId).digest("hex");}Example Backend Endpoint (Node.js)
app.post("/api/ztg/session", async (req, res) => { const { publicKeys } = req.body;
// Hash the customer ID — Layr8 never sees the raw ID const saltedId = crypto .createHash("sha256") .update(SALT + req.user.id) .digest("hex");
// Request a session token from the ZTG proxy in your infrastructure const response = await fetch(`${ZTG_PROXY_URL}/ztg/tokenize-session`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ salted_customer_id: saltedId, public_keys: publicKeys, }), });
const { session_token } = await response.json(); res.json({ sessionToken: session_token });});Multi-Key Onboarding
Capture multiple API keys in a single form. Use tokenizeAll() to tokenize all fields in one call — the SDK creates a single session and tokenizes each field:
import type { GetSession } from "@layr8/ztg-react";
const getSession: GetSession = async (publicKeys) => { const res = await fetch("/api/ztg/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ publicKeys }), }); return (await res.json()).sessionToken;};
function MultiKeyForm() { const ztg = useZTG();
const handleSubmit = async () => { const results = await ztg.tokenizeAll(getSession, { read: { label: "read" }, write: { label: "write" }, }); // results.read.tokenId, results.write.tokenId };
return ( <form onSubmit={handleSubmit}> <div> <label>Read Key</label> <SecretField id="read" placeholder="Read API key" /> </div> <div> <label>Write Key</label> <SecretField id="write" placeholder="Write API key" /> </div> <button>Save All Keys</button> </form> );}The label parameter differentiates keys for the same customer and API. For example, use "read" and "write" to capture separate keys with different permission levels.
Key Rotation
When a user rotates their API key, call tokenize() again with the same getSession callback. Layr8 uses deterministic token IDs — same customer, same API, same label always produces the same tokenId. The old secret is replaced; no code changes needed on your end.
// First onboardingconst { tokenId } = await ztg.tokenize(getSession);// tokenId = "tok_a1b2c3d4e5f6g7h8"
// Later rotation — same tokenId, new secretconst { tokenId: rotatedTokenId } = await ztg.tokenize(getSession);// rotatedTokenId === tokenIdYour backend stores the tokenId once. When the secret changes, the same tokenId resolves to the new value automatically.
Using Stored Keys
After tokenization, your application makes API calls through the ZTG proxy. Replace secret values with ${tokenId} placeholders, and include the X-Ztg-Customer-Id header so the vault-proxy knows which customer’s tokens to resolve:
GET /v1/charges HTTP/1.1Host: ztg-proxy.your-vpc.internal:9080Authorization: Bearer ${tok_a1b2c3d4e5f6g7h8}X-Ztg-Customer-Id: c8f3e2a1b4d5f6e7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1Required on every proxied request:
| Header | Description |
|---|---|
X-Ztg-Customer-Id | The same salted_customer_id used when creating the session. Consumed by the vault-proxy and not forwarded to the target API. |
Layr8 scans both headers and the request body for ${...} placeholders, resolves them to actual secret values, strips the X-Ztg-Customer-Id header, and forwards the request to the target API. Your application never sees the plaintext.
// Example: proxying an API call through the http-proxyconst saltedId = saltCustomerId(user.id);
const response = await fetch(`http://ztg-proxy.internal:9080/v1/charges`, { method: "POST", headers: { Authorization: `Bearer \${${tokenId}}`, "X-Ztg-Customer-Id": saltedId, "Content-Type": "application/x-www-form-urlencoded", }, body: "amount=2000¤cy=usd",});Security
What the Web Element Protects Against
| Threat | How |
|---|---|
| XSS on your page | The secret input is isolated in a separate origin — your JavaScript cannot read it |
| Malicious dependencies | The input lives in Layr8’s secure frame, outside your application’s DOM |
| Man-in-the-middle | All communication is over HTTPS; secrets are never sent to your servers |
| Stolen session tokens | Tokens are bound to the originating browser via proof-of-possession and expire after 5 minutes |
| Secret sent to wrong API | Each secret is bound to a specific target API at tokenization time |
| Cross-customer access | Secrets are scoped per-customer — one customer cannot access another’s keys |
Limitations
These limitations apply to any browser-based input and are outside the ZTG threat model:
- Browser DevTools — A privileged debugging tool with full access to the page
- Malicious browser extensions — Extensions with broad permissions can read any input on any page
- Compromised browser — If the browser itself is compromised, no web-based protection is possible
- Physical keyloggers — Hardware-level input capture
- Target API echoing secrets in responses — If a target API returns your authentication credentials in its response body or headers, those values pass back through the proxy to your application. This is uncommon — mainstream APIs (Stripe, OpenAI, Twilio, etc.) do not echo auth headers in responses — but if you integrate with an API that does, be aware that the response path is not scrubbed
How Secrets Are Protected
Secrets are encrypted with HSM-backed keys and stored in hardware-isolated infrastructure. Your application, your servers, and Layr8’s application layer never have access to plaintext secrets. Secrets are decrypted only at the moment they are injected into outbound API requests, inside isolated hardware.
API Reference
loadZTG(apiOrigin)
Loads the SDK and returns a ZTG instance.
const ztg = await loadZTG("https://ztg-vault-proxy.layr8.cloud");| Parameter | Type | Description |
|---|---|---|
apiOrigin | string | Your organization’s ZTG endpoint |
ztg.createField(selector, options)
Mounts a secure input field into a DOM element.
const field = ztg.createField("#container", { placeholder: "Enter API key", label: "OpenAI API Key", style: { fontSize: "16px" },});| Parameter | Type | Description |
|---|---|---|
selector | string | CSS selector for the container element |
options.placeholder | string | Placeholder text for the input |
options.label | string | Accessible label for the input |
options.style | object | CSS properties applied to the input |
field.on(event, callback)
Listens for field state changes. The callback receives state information — never the secret value.
field.on("change", (state) => { console.log(state.empty); // true if the field is empty console.log(state.focused); // true if the field has focus});ztg.tokenize(getSession, options?)
Encrypts and stores the secret from the active field. The secure iframe generates security keys internally, the SDK calls your getSession callback with the public key thumbprints, delivers the session token to the secure iframe, and tokenizes the secret. Returns an opaque token reference.
const getSession: GetSession = async (publicKeys: string[]) => { const res = await fetch("/api/session", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ publicKeys }), }); return (await res.json()).sessionToken;};
const { tokenId } = await ztg.tokenize(getSession, { label: "default", // optional, defaults to "default"});| Parameter | Type | Description |
|---|---|---|
getSession | (publicKeys: string[]) => Promise<string> | Callback that receives security key thumbprints and returns a session token from your backend |
options.label | string | Label for multi-key scenarios (default: "default") |
Returns: Promise<{ tokenId: string }>
ztg.tokenizeAll(getSession, fields)
Tokenizes multiple fields in a single call. The SDK creates one session and tokenizes each field.
const results = await ztg.tokenizeAll(getSession, { read: { label: "read" }, write: { label: "write" },});// results.read.tokenId, results.write.tokenId| Parameter | Type | Description |
|---|---|---|
getSession | (publicKeys: string[]) => Promise<string> | Callback that receives security key thumbprints and returns a session token |
fields | Record<string, { label?: string }> | Map of field IDs to tokenization options |
Returns: Promise<Record<string, { tokenId: string }>>
field.destroy()
Removes the field and cleans up resources.
Next Steps
- ZTG Integration Guide — Full end-to-end setup: deploy the proxy, capture a key, proxy your first request
- Architecture — How Layr8 fits into your infrastructure
- Boundaries & Assumptions — What Layr8 guarantees and what it doesn’t