Skip to content

Service Account Token Flow for Frontend Apps

This guide shows a safe token handoff pattern for frontend apps:

  1. The frontend asks your backend for a temporary consumer token.
  2. Your backend authenticates to the Merchants backend with a service account (private key based).
  3. Your backend requests a short-lived site access token.
  4. The backend returns that temporary token to the frontend.
  5. The frontend uses the token as a Bearer token when calling Ingrid APIs.

Why this pattern

  • 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.

Architecture

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>

Backend example (Node.js)

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();
}

Frontend example

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),
});

Security checklist

  • Never expose service account private keys to the client.
  • Keep temporary token TTL short (for example 5 minutes).
  • Scope token permissions narrowly (for example view only).
  • Authenticate your own user/session before issuing tokens.
  • Log token issuance with externalRef for traceability.
  • Rotate service account keys regularly.

Operational notes

  • 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-token to protect against abuse.