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.
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.
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.PARTNER_SECRET, which only you and Fruga know, and carries a kid header so Fruga knows which secret to verify against.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.
| Value | Example | Where it is used | Safe in frontend? |
|---|---|---|---|
NEXT_PUBLIC_PARTNER_ID | pk_dev_123 | Your Partner Key. Passed to the widget, and used as the partnerKey query parameter when loading your configuration | Yes - it is a public identifier |
PARTNER_SECRET | sk_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_ID | key_1 | The kid value in the assertion’s JWT header, identifying which secret Fruga should verify against | No |
ISSUER | partner:1 | The iss claim in the assertion | No |
{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
| Claim | Type | Required | Description |
|---|---|---|---|
iss | string | Yes | Your issued ISSUER value - e.g. partner:1 |
aud | string | Yes | The auth.audience value returned by the bootstrap endpoint - see below. Do not hardcode it |
iat | unix timestamp | Yes | Time the token was issued, in seconds |
exp | unix timestamp | Yes | Expiry time - must be no more than 120 seconds after iat |
jti | string (UUID) | Yes | A unique identifier for this token - prevents the same assertion being used twice |
userRef | string | Yes | Your 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"
}
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:
| Check | What Fruga verifies |
|---|---|
| Key lookup | The kid header maps to a known, active partner key - this determines which secret is used for the next check |
| Signature | The JWT signature is valid against the PARTNER_SECRET associated with that key |
| Audience | aud matches the audience for your partner environment - the same value bootstrap returns |
| Issuer | iss matches your issued ISSUER value |
| Expiry | exp has not passed - the token has not expired |
| Replay protection | The jti has not been used before within its TTL window |
| User upsert | A 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.
| Error | Likely cause | Fix |
|---|---|---|
401 Unauthorized | Missing or unknown kid header - Fruga cannot tell which secret to verify with | Confirm 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 Unauthorized | Invalid signature - the assertion could not be verified | Confirm 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 issuer | The iss claim does not match your issued ISSUER | Set iss to exactly the ISSUER value Fruga provided - e.g. partner:1. Do not use your Partner Key here. |
401 - token expired | The assertion has already passed its exp time | Ensure your server clock is accurate (NTP-synced). Sign and exchange the assertion in the same request - do not cache them. |
401 - invalid audience | The aud claim does not match what Fruga expects | Read aud from GET /widget/bootstrap → auth.audience rather than hardcoding it - no trailing slashes or additional values. |
409 Conflict | Replay detected - the jti has already been used | Generate a fresh UUID for jti on every assertion. Never reuse or cache assertion tokens. |
| Widget loads but shows no user data | The userRef does not match the value used when the cashback claim was submitted | Ensure the userRef in the assertion is the same stable identifier you pass as userRef in cashback claim calls. It must be consistent across both. |
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:
| Item | Frontend safe? | Notes |
|---|---|---|
NEXT_PUBLIC_PARTNER_ID (pk_...) | Yes | Public identifier - safe in JavaScript, HTML, or any client-side context |
PARTNER_SECRET (sk_...) | No | Store in your secrets manager. Rotate immediately if exposed. |
PARTNER_KEY_ID / ISSUER | No | Not 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 JWT | Never reaches the browser | Created and exchanged entirely within your backend. If you find yourself returning it to the client, the flow is wrong. |
| Fruga access token | Yes | Returned 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.