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.

Required JWT claims

Required claims

issstringrequired

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

audstringrequired

Must be api://yieldforce. This value is hardcoded on the backend and is not configurable per tenant.

substringrequired

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.

expnumberrequired

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.

iatnumberoptional

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

algstringrequired

Must be RS256. No other algorithm is accepted. HMAC (HS*), ECDSA (ES*), and none are all rejected.

kidstringrequired

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.

typstringoptional

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 in Authorization — 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 missing X-User-Token as missing). Sending only the partner JWT (no Authorization) results in 401 invalid_entra_token because 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:

  1. Generate a new RSA-2048 keypair with a new kid (e.g., partner-key-2).
  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.
  3. Start minting new JWTs using the new private key and kid.
  4. Wait until all JWTs signed with the old kid have expired (typically 1 hour after you switched over, matching your exp TTL).
  5. Remove the old kid from 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

HTTPCodeMeaningFix
401invalid_user_tokenSignature 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.
401unknown_partner_issuerThe 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).
401sub_url_mismatchThe 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.
401cross_tenant_jwtThe 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.