User authentication
A user token identifies an individual end-user within your tenant. It is layered on top of your Entra token via a separate X-User-Token header — the two tokens are independent and serve different purposes: the Entra token proves your tenant identity, the user token proves which of your users is acting.
User tokens are RS256-signed JWTs issued by the partner (you), verified by Yieldforce via your published JWKS endpoint.
When you need it
Every endpoint under /v1/partner/end_users/{external_id}/* requires a user token. This includes get-end-user, list portfolios, create portfolio, wallet balance, list transactions, deposit, and withdraw.
Endpoints that operate at the tenant level — list pools (GET /v1/partner/pools), create end-user (POST /v1/partner/end_users), and delete end-user (DELETE /v1/partner/end_users/{external_id}) — require only the Entra token.
JWKS endpoint requirements
Before Yieldforce can validate any partner JWT, your JWKS endpoint must be reachable and correctly structured.
- HTTPS in production. HTTP is only acceptable during local development using a tunnel such as ngrok. Your registered
jwksUrlmust usehttps://in staging and production environments. - RFC 5785 path. Serve the JWK Set at
/.well-known/jwks.jsonon a root-level domain (e.g.,https://your-domain/.well-known/jwks.json). The demo implements this atsrc/app/.well-known/jwks.json/route.ts. - JWK Set format. The response body must be a JSON object with a
keysarray. Each key object must includekid,kty,n,e,use, andalg. - No authentication required. The endpoint must be publicly accessible — Yieldforce fetches it without any credentials.
- Caching is fine. The demo sets
Cache-Control: public, max-age=3600. Yieldforce's JWKS client also caches keys perkidfor 24 hours. This means key rotation works without coordination: as long as you keep oldkidvalues in your JWKS response until all JWTs signed with them expire, validation continues uninterrupted. See Key rotation below.
Required JWT claims
Required claims
Your issuer URL. Must exactly match the issuer URL registered for your tenant — including scheme, host, port, and path. A trailing slash in one but
not the other causes unknown_partner_issuer. The demo defaults to http://localhost:3500 (env
var PARTNER_ISSUER).
Must be api://yieldforce. This value is hardcoded on the backend and is not configurable per
tenant.
The end-user's external_id. Must match the {external_id} path segment of the endpoint being
called. A mismatch results in sub_url_mismatch — this prevents one user's token from being
used to access another user's resources.
Token expiry as epoch seconds. Recommend a 1-hour TTL (setExpirationTime('1h')). Short enough
to limit exposure if a token leaks; long enough to cover a user session without re-minting
mid-flow.
Issued-at time as epoch seconds. Recommended for auditability even though the backend does not currently reject tokens missing it.
JWT header
Required header fields
Must be RS256. No other algorithm is accepted. HMAC (HS*), ECDSA (ES*), and none are all
rejected.
The key ID of the signing key. Must match a kid present in your JWKS response at the time
Yieldforce validates the token. If the kid is missing from the JWKS response, validation fails
with invalid_user_token.
Recommended to be JWT. Included by the demo; not currently enforced by the backend.
Minting (TS example)
The following snippet mirrors src/lib/jwt.ts exactly, using the jose library:
import { SignJWT, importPKCS8 } from 'jose';
const KID = 'partner-demo-key-1'; // must match a kid in your JWKS response
export async function mintPartnerJwt(userId: string): Promise<string> {
const privatePem = process.env.PARTNER_PRIVATE_KEY_PEM!;
const issuer = process.env.PARTNER_ISSUER ?? 'http://localhost:3500';
const audience = process.env.PARTNER_AUDIENCE ?? 'api://yieldforce';
const ttl = Number.parseInt(process.env.PARTNER_JWT_TTL_SECONDS ?? '3600', 10);
const privateKey = await importPKCS8(privatePem, 'RS256');
return new SignJWT({})
.setProtectedHeader({ alg: 'RS256', kid: KID, typ: 'JWT' })
.setSubject(userId) // the end-user's external_id
.setIssuer(issuer) // must match your registered JWKS issuer URL
.setAudience(audience) // must be 'api://yieldforce'
.setIssuedAt()
.setExpirationTime(`${ttl}s`)
.sign(privateKey);
}privatePem is the PKCS#8 PEM of your RSA-2048 private key. In the demo this is loaded from PARTNER_PRIVATE_KEY_PEM env var or from ./.keys/private.pem — see src/lib/keys.ts for the load/generate logic.
How Yieldforce validates
The backend caches public keys per kid for 24 hours. Signature verification uses RS256 exclusively — the algorithm is enforced on the backend and is not read from the token header.
Sending the JWT
- Use header
X-User-Token: <jwt>for the partner JWT. Do not put it inAuthorization— that header is reserved for your Entra token. - Both headers must be present on user-scoped endpoints. Sending only the Entra token results in
401 token_missing(the backend treats a missingX-User-Tokenas missing). Sending only the partner JWT (noAuthorization) results in401 invalid_entra_tokenbecause Entra validation runs first.
A correct user-scoped request includes both:
const res = await fetch(`${baseUrl}/v1/partner/end_users/${externalId}/portfolios`, {
headers: {
Authorization: `Bearer ${entraToken}`, // Entra — tenant identity
'X-User-Token': partnerJwt, // user-scoped — user identity
'Content-Type': 'application/json',
},
});Key rotation
Rotate your signing key without any downtime or coordination with Yieldforce:
- Generate a new RSA-2048 keypair with a new
kid(e.g.,partner-key-2). - Add the new public key (with the new
kid) to your JWKS endpoint response. Keep the old key in the response — do not remove it yet. - Start minting new JWTs using the new private key and
kid. - Wait until all JWTs signed with the old
kidhave expired (typically 1 hour after you switched over, matching yourexpTTL). - Remove the old
kidfrom your JWKS endpoint response.
Yieldforce's JWKS client caches keys per kid for 24 hours. When a token arrives with a kid not found in the cache, the client re-fetches your JWKS endpoint immediately. As long as the old key remains in your JWKS response during the transition window, validation continues uninterrupted for in-flight tokens.
Common errors
| HTTP | Code | Meaning | Fix |
|---|---|---|---|
401 | invalid_user_token | Signature verification failed, or the token is malformed/expired. | Verify your JWKS endpoint is reachable and returns the correct public key for the kid in the token. Check that aud is exactly api://yieldforce. |
401 | unknown_partner_issuer | The iss claim does not match any registered tenant's expectedIssuer. | Ensure PARTNER_ISSUER matches the value registered for your tenant exactly (no trailing slash difference, correct scheme and port). |
401 | sub_url_mismatch | The sub claim does not match the {external_id} path segment of the request URL. | Mint the JWT with setSubject(externalId) where externalId is the same value used in the URL. Never reuse a JWT minted for one user on a request for a different user. |
401 | cross_tenant_jwt | The iss in the partner JWT resolves to a different Yieldforce tenant than the appid in the Entra token. | Verify that your Entra App Registration (ENTRA_CLIENT_ID) and your JWKS issuer URL (PARTNER_ISSUER) are both registered under the same Yieldforce tenant. |