Authentication

Securely authenticate your users with the Fruga platform.

⚠️
Your Signing Secret must never leave your server. It must not appear in frontend code, environment variables committed to source control, or any client-side context. Treat it like a private key.

How the model works

When a user opens your platform and the Relay widget loads, three things happen in sequence — two of which are invisible to the user entirely.

1
The widget identifies your platform
The widget loads your partner configuration using your Partner Key — a public identifier that determines which environment, theme, and feature set to use. This happens automatically when the widget initialises.
2
Your backend vouches for the user
Your server generates a short-lived signed JWT — called an assertion — that states "this user exists in our system and we vouch for them." The assertion is signed with your Signing Secret, which only you and Fruga know.
3
Fruga exchanges the assertion for an access token
The widget sends the assertion to Fruga's token exchange endpoint. Fruga verifies the signature, checks the claims, and if everything is valid, issues a Fruga access token. The widget uses this token for all subsequent requests — the user is now authenticated.

The key insight is that Fruga never manages a login flow for your users. You remain the authority on who is authenticated in your platform. Fruga simply trusts your signed assertion and acts on it.


Your credentials

Fruga provides two credentials for each environment (sandbox and production). You will receive these from the Fruga integrations team before you begin your integration.

CredentialFormatWhere it is usedSafe in frontend?
Partner Keypk_test_... / pk_live_...Passed to Fruga.init() in your frontend code to identify your partner environmentYes — it is a public identifier
Signing Secretsk_test_... / sk_live_...Used on your backend to sign assertion JWTs. Never passed to the widget or browser.No — keep this server-side only
💡
You will receive separate credentials for sandbox and production. Always use sandbox credentials (`pk_test_...` / `sk_test_...`) during development and testing. See the [Sandbox & Testing guide](/guides/sandbox) for details.

The assertion JWT

An assertion is a standard JWT that your backend generates fresh for each widget session. It has a very short lifespan — 60 seconds is recommended, 120 seconds is the maximum — because it is a one-time proof of identity, not a session token. Once exchanged for a Fruga access token, the assertion cannot be reused.

Required claims

ClaimTypeRequiredDescription
issstringYesYour partner identifier, provided by Fruga — e.g. partner:p_123
audstringYesMust be exactly fruga:external_token_exchange
iatunix timestampYesTime the token was issued, in seconds
expunix timestampYesExpiry time — must be no more than 120 seconds after iat
jtistring (UUID)YesA unique identifier for this token — prevents the same assertion being used twice
userRefstringYesYour internal identifier for the current user — can be any stable, unique string in your system

Example payload

{
  "iss": "partner:p_123",
  "aud": "fruga:external_token_exchange",
  "iat": 1739819000,
  "exp": 1739819060,        // 60 seconds after iat
  "jti": "0c3f5c3a-8d2e-4d4f-9e7a-62b2c0b7d8d1",
  "userRef": "user_123"
}

JWT header

Set the algorithm to HS256. No other fields are required in the header for V1.

{
  "alg": "HS256",
  "typ": "JWT"
}

Signing the assertion

Sign the JWT using HMAC-SHA256 with your Signing Secret. The following examples show how to do this in common backend languages.

Node.js

import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';

const generateAssertion = (userId) => {
  const now = Math.floor(Date.now() / 1000);
  return jwt.sign(
    {
      iss: 'partner:p_123',
      aud: 'fruga:external_token_exchange',
      iat: now,
      exp: now + 60,
      jti: uuidv4(),
      userRef: userId,
    },
    process.env.FRUGA_SIGNING_SECRET,
    { algorithm: 'HS256' }
  );
};

Python

import jwt
import uuid
import time
import os

def generate_assertion(user_id):
    now = int(time.time())
    payload = {
        "iss": "partner:p_123",
        "aud": "fruga:external_token_exchange",
        "iat": now,
        "exp": now + 60,
        "jti": str(uuid.uuid4()),
        "userRef": user_id,
    }
    return jwt.encode(
        payload,
        os.environ["FRUGA_SIGNING_SECRET"],
        algorithm="HS256"
    )

What Fruga checks when it receives your assertion

Understanding the server-side verification steps helps you debug failures quickly. When your assertion reaches Fruga’s POST /auth/external/token endpoint, the following checks run in order:

CheckWhat Fruga verifies
Partner lookupThe partnerKey in the request body maps to a known, active partner environment
SignatureThe JWT signature is valid against the Signing Secret associated with that partner environment
Audienceaud is exactly fruga:external_token_exchange
Issueriss matches the partner identifier on record
Expiryexp has not passed — the token has not expired
Replay protectionThe jti has not been used before within its TTL window
User upsertA user record is created or confirmed for the userRef within this partner’s scope

All checks must pass. If any fail, the exchange returns an error and no access token is issued.


The Fruga access token

On a successful exchange, Fruga issues its own short-lived access token to the widget. This is separate from your assertion — it is Fruga’s internal session token and is handled entirely by the widget SDK. You do not need to store, refresh, or interact with it directly.

The access token has a 15-minute lifetime. The widget manages its own refresh cycle — if a user’s session extends beyond 15 minutes, the widget will silently request a new assertion from your backend and exchange it for a fresh access token. Make sure your assertion endpoint remains available for the duration of a user’s session.


Troubleshooting

The following table covers the most common authentication failures and their causes.

ErrorLikely causeFix
401 UnauthorizedInvalid signature — the assertion could not be verifiedConfirm you are using the correct Signing Secret for the environment (sandbox vs production). Check that the secret has not been accidentally truncated or URL-encoded.
401 — token expiredThe assertion has already passed its exp timeEnsure your server clock is accurate (NTP-synced). Generate assertions as close to widget initialisation as possible — do not cache them.
401 — invalid audienceThe aud claim does not match what Fruga expectsSet aud to exactly fruga:external_token_exchange — no trailing slashes or additional values.
409 ConflictReplay detected — the jti has already been usedGenerate a fresh UUID for jti on every assertion. Never reuse or cache assertion tokens.
Widget loads but shows no user dataThe userRef does not match the value used when the cashback claim was submittedEnsure the userRef in the assertion is the same stable identifier you pass as userRef in cashback claim calls. It must be consistent across both.
💡
The fastest way to debug an assertion is to decode it at jwt.io and visually inspect the claims before sending. You can also verify the signature there using your Signing Secret — useful during initial setup.

Security considerations

The assertion pattern is designed so that sensitive credentials never touch the browser. Here is a summary of what is safe where:

ItemFrontend safe?Notes
Partner Key (pk_...)YesPublic identifier — safe in JavaScript, HTML, or any client-side context
Signing Secret (sk_...)NoStore in your secrets manager. Rotate immediately if exposed.
Assertion JWTYes — brieflyIt is passed through the browser, but it expires in 60 seconds and is single-use. Its exposure window is negligible.
Fruga access tokenYesManaged entirely by the widget SDK inside an iframe — your host page never has access to it

The widget runs inside an iframe, which means your host page is isolated from the Fruga access token by browser same-origin policy. Even if a user inspects your page, they cannot extract the Fruga session.