Authentication

Relay uses a two-layer authentication model - one layer identifies your platform, the other identifies individual users. This page explains the concepts behind the model, how the two layers work together, and what your backend needs to implement.

⚠️
Your PARTNER_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
Your platform is identified by the Partner Key
Your NEXT_PUBLIC_PARTNER_ID is a public identifier that determines which environment, theme, and feature set to use. The widget uses it to load your configuration, and your backend uses it to look up the audience to sign with.
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 PARTNER_SECRET, which only you and Fruga know, and carries a kid header so Fruga knows which secret to verify against.
3
Your backend exchanges the assertion for an access token
Your server posts 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. Your endpoint returns that token to the widget, which uses it for all subsequent requests - the user is now authenticated.

The assertion is a server-to-server credential: it is created and spent entirely inside your backend, and never passes through the browser.

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 four values for each environment (sandbox and production). You will receive these from the Fruga integrations team before you begin your integration.

ValueExampleWhere it is usedSafe in frontend?
NEXT_PUBLIC_PARTNER_IDpk_dev_123Your Partner Key. Passed to the widget, and used as the partnerKey query parameter when loading your configurationYes - it is a public identifier
PARTNER_SECRETsk_dev_...The HMAC key used on your backend to sign assertion JWTs. Never passed to the widget or browser.No - keep this server-side only
PARTNER_KEY_IDkey_1The kid value in the assertion’s JWT header, identifying which secret Fruga should verify againstNo
ISSUERpartner:1The iss claim in the assertionNo
💡
You will receive a separate set of these four values for sandbox and production, and each environment has its own API domain. {FRUGA_API_URL} in the examples below is a placeholder for that domain - substitute the one for the environment you are pointing at. Always use the sandbox set during development and testing; see the Sandbox & Testing guide for details.

The assertion JWT

An assertion is a standard JWT that your backend generates fresh each time the widget needs a token. It has a very short lifespan - 15 to 30 seconds is recommended, 120 seconds is the maximum - because it is a one-time proof of identity, not a session token. Fruga’s own reference implementation uses 16 seconds. Since the assertion is signed and spent within the same request on your server, it never needs to survive a round trip to the browser.

Required claims

ClaimTypeRequiredDescription
issstringYesYour issued ISSUER value - e.g. partner:1
audstringYesThe auth.audience value returned by the bootstrap endpoint - see below. Do not hardcode it
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:1",
  "aud": "fruga:external_session",
  "iat": 1739819000,
  "exp": 1739819016,
  "jti": "0c3f5c3a-8d2e-4d4f-9e7a-62b2c0b7d8d1",
  "userRef": "user_123"
}

Fetching the audience

Rather than hardcoding aud, read it from the bootstrap endpoint using your Partner Key. This is the same configuration call the widget makes, and it is safe to call from your backend on every token request.

GET {FRUGA_API_URL}/widget/bootstrap?partnerKey=pk_dev_123

The response includes an auth object:

{
  "auth": {
    "mode": "external",
    "audience": "fruga:external_session",
    "issuer": "partner:1",
    "tokenTtlSeconds": 900
  }
}

Use auth.audience as the aud claim. The current sandbox value is fruga:external_session, but reading it from bootstrap means your integration keeps working if it changes.

JWT header

Set the algorithm to HS256 and include a kid header set to your PARTNER_KEY_ID. The kid is required - Fruga uses it to select which partner secret to verify the signature against.

{
  "alg": "HS256",
  "typ": "JWT",
  "kid": "key_1"
}
⚠️
If PARTNER_KEY_ID is unset, most JWT libraries omit the kid header silently rather than throwing. The resulting 401 looks identical to a wrong secret, so check kid is present before suspecting PARTNER_SECRET.

Signing the assertion

Sign the JWT using HMAC-SHA256 with your PARTNER_SECRET. The secret is used as its raw UTF-8 bytes - do not base64-decode it first. The following examples show how to do this in common backend languages; both take the audience fetched from bootstrap.

Node.js

import * as jose from 'jose';

const generateAssertion = async (userRef, audience) => {
  const secret = new TextEncoder().encode(process.env.PARTNER_SECRET);

  return new jose.SignJWT({ userRef })
    .setProtectedHeader({
      alg: 'HS256',
      typ: 'JWT',
      kid: process.env.PARTNER_KEY_ID,
    })
    .setIssuedAt()
    .setIssuer(process.env.ISSUER)
    .setAudience(audience)
    .setExpirationTime('16s')
    .setJti(crypto.randomUUID())
    .sign(secret);
};

Python

import jwt
import uuid
import time
import os

def generate_assertion(user_ref, audience):
    now = int(time.time())
    payload = {
        "iss": os.environ["ISSUER"],
        "aud": audience,
        "iat": now,
        "exp": now + 16,
        "jti": str(uuid.uuid4()),
        "userRef": user_ref,
    }
    return jwt.encode(
        payload,
        os.environ["PARTNER_SECRET"],
        algorithm="HS256",
        headers={"kid": os.environ["PARTNER_KEY_ID"]},
    )

Exchanging the assertion

Your backend posts the assertion to the token exchange endpoint and returns the result to the browser. The response contains the accessToken your onTokenRequired callback should return.

POST {FRUGA_API_URL}/auth/external/token
Content-Type: application/json

{ "assertion": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleV8xIn0..." }

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
Key lookupThe kid header maps to a known, active partner key - this determines which secret is used for the next check
SignatureThe JWT signature is valid against the PARTNER_SECRET associated with that key
Audienceaud matches the audience for your partner environment - the same value bootstrap returns
Issueriss matches your issued ISSUER value
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. Your endpoint passes it back to the browser, and the widget uses it for all subsequent requests. This is separate from your assertion - it is Fruga’s session token, and beyond returning it from onTokenRequired you do not need to store, refresh, or interact with it directly.

The access token has a 15-minute lifetime (also advertised as auth.tokenTtlSeconds on bootstrap). The widget manages its own refresh cycle - if a user’s session extends beyond that, the widget calls onTokenRequired again, your backend signs and exchanges a fresh assertion, and the widget continues uninterrupted. Make sure your token 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 UnauthorizedMissing or unknown kid header - Fruga cannot tell which secret to verify withConfirm PARTNER_KEY_ID is set in your server environment and reaches the JWT header. Decode the assertion and check the header actually contains kid.
401 UnauthorizedInvalid signature - the assertion could not be verifiedConfirm you are using the correct PARTNER_SECRET for the environment (sandbox vs production), signed as raw UTF-8 bytes. Check that the secret has not been accidentally truncated or URL-encoded.
401 - invalid issuerThe iss claim does not match your issued ISSUERSet iss to exactly the ISSUER value Fruga provided - e.g. partner:1. Do not use your Partner Key here.
401 - token expiredThe assertion has already passed its exp timeEnsure your server clock is accurate (NTP-synced). Sign and exchange the assertion in the same request - do not cache them.
401 - invalid audienceThe aud claim does not match what Fruga expectsRead aud from GET /widget/bootstrapauth.audience rather than hardcoding it - 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 log it from your backend, decode it at jwt.io, and inspect the header and claims - checking kid, iss and aud first. You can also verify the signature there using your PARTNER_SECRET - useful during initial setup. Never log assertions in production.

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
NEXT_PUBLIC_PARTNER_ID (pk_...)YesPublic identifier - safe in JavaScript, HTML, or any client-side context
PARTNER_SECRET (sk_...)NoStore in your secrets manager. Rotate immediately if exposed.
PARTNER_KEY_ID / ISSUERNoNot secrets, but there is no reason to ship them to the browser - they are only ever used when signing. Keep them as server-side config.
Assertion JWTNever reaches the browserCreated and exchanged entirely within your backend. If you find yourself returning it to the client, the flow is wrong.
Fruga access tokenYesReturned to the browser by your endpoint and handed to the widget. Short-lived and scoped to a single user.

Two things follow from this design. Your PARTNER_SECRET is only ever used inside one server function, so the surface area for a leak is a single file. And because your endpoint decides which userRef to sign for, the browser can never request a token for a user other than the one it is authenticated as - provided you check the session server-side before signing.