This guide shows a safe token handoff pattern for frontend apps:
- The frontend asks your backend for a temporary consumer token.
- Your backend authenticates to the Merchants backend with a service account (private key based).
- Your backend requests a short-lived site access token.
- The backend returns that temporary token to the frontend.
- The frontend uses the token as a Bearer token when calling Ingrid APIs.
- Keeps service account private keys on the server only.
- Issues short-lived tokens to browsers/mobile apps.
- Reduces blast radius if a consumer token leaks.
- Lets you add your own authorization checks before minting a token.
Frontend app
-> POST /api/frontend-token (your backend)
-> Authenticate service account with private key (Merchants backend)
-> POST /v1/sites/{siteId}/access-tokens (Merchants backend)
<- { accessToken, tokenType, expiresAt, expiresIn }
Frontend app
-> Ingrid APIs with Authorization: Bearer <accessToken>The exact service-account auth endpoint can differ by environment/version. In this example it is represented as MERCHANTS_AUTH_URL.
import crypto from "node:crypto";
const MERCHANTS_API_BASE = process.env.MERCHANTS_API_BASE!;
const MERCHANTS_AUTH_URL = process.env.MERCHANTS_AUTH_URL!;
const SERVICE_ACCOUNT_ID = process.env.SERVICE_ACCOUNT_ID!;
const SERVICE_ACCOUNT_PRIVATE_KEY = process.env.SERVICE_ACCOUNT_PRIVATE_KEY!;
const SITE_ID = process.env.SITE_ID!;
function createSignedAssertion() {
const now = Math.floor(Date.now() / 1000);
const payload = {
sub: SERVICE_ACCOUNT_ID,
iat: now,
exp: now + 60,
aud: MERCHANTS_AUTH_URL,
};
const header = { alg: "RS256", typ: "JWT" };
const encode = (obj: object) =>
Buffer.from(JSON.stringify(obj)).toString("base64url");
const input = `${encode(header)}.${encode(payload)}`;
const signature = crypto
.createSign("RSA-SHA256")
.update(input)
.end()
.sign(SERVICE_ACCOUNT_PRIVATE_KEY, "base64url");
return `${input}.${signature}`;
}
async function getServiceAccountAccessToken() {
const assertion = createSignedAssertion();
const response = await fetch(MERCHANTS_AUTH_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grantType: "urn:ietf:params:oauth:grant-type:jwt-bearer",
assertion,
}),
});
if (!response.ok) {
throw new Error(`Service account auth failed: ${response.status}`);
}
const data = await response.json();
return data.accessToken as string;
}
export async function issueFrontendToken() {
const serviceToken = await getServiceAccountAccessToken();
const response = await fetch(
`${MERCHANTS_API_BASE}/v1/sites/${SITE_ID}/access-tokens`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${serviceToken}`,
},
body: JSON.stringify({
scopes: ["view"],
ttlSeconds: 300,
externalRef: "frontend-session",
}),
},
);
if (!response.ok) {
throw new Error(`Access token mint failed: ${response.status}`);
}
return response.json();
}export async function fetchFrontendToken() {
const res = await fetch("/api/frontend-token", {
method: "POST",
credentials: "include",
});
if (!res.ok) {
throw new Error("Could not get frontend token");
}
return (await res.json()) as {
accessToken: string;
tokenType: "Bearer";
expiresAt: string;
expiresIn: number;
};
}Use the returned token in API calls:
const { accessToken } = await fetchFrontendToken();
await fetch("https://api.ingrid.com/v1/delivery/options", {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(payload),
});- Never expose service account private keys to the client.
- Keep temporary token TTL short (for example 5 minutes).
- Scope token permissions narrowly (for example
viewonly). - Authenticate your own user/session before issuing tokens.
- Log token issuance with
externalReffor traceability. - Rotate service account keys regularly.
- Refresh on expiry: when frontend receives
401, fetch a fresh temporary token and retry once. - Cache server-side service-account auth token briefly to reduce auth calls.
- Add rate limits on
/api/frontend-tokento protect against abuse.