# OAuth 2.0 / OpenID Connect

Add a "Sign in with Bynefit" button to your site or app. Bynefit is a standards-compliant OAuth 2.0 / OpenID Connect provider — the same flow you use for Google, Apple, or Microsoft.

## Issuer

All flows start at `id.bynefit.com` — the dedicated auth domain, which serves the endpoints below and nothing else. Bookmark the discovery document if your client supports auto-configuration:

**OpenID configuration (JSON)**

```json
GET https://id.bynefit.com/.well-known/openid-configuration
```

It advertises the canonical endpoints:

| Endpoint | URL |
| --- | --- |
| Authorization | `https://id.bynefit.com/oauth/authorize` |
| Token | `https://id.bynefit.com/oauth/token` |
| UserInfo | `https://id.bynefit.com/oauth/userinfo` |
| JWKS | `https://id.bynefit.com/oauth/jwks.json` |

Tokens are signed RS256. Always validate `iss` equals `https://id.bynefit.com` and `aud` equals your `client_id` before trusting the claims.

## Register a client

Team admins register OAuth clients at [/dash/sites/oauth](https://bynefit.com/dash/sites/oauth). Each client is scoped to one team and gets a unique `client_id`.

Site-registered clients are **public clients**:

- No `client_secret` — they run in browsers and can't keep one.
- **PKCE is required** on every authorization request.
- Scopes are `openid profile email` to start. `creator.read` and `earnings.read` can be asked for on the client's page and are approved by Bynefit staff (see Scopes).
- Redirect URIs are exact-matched and must be HTTPS (or `http://localhost` for development).

## The flow (PKCE)

1. Generate a high-entropy `code_verifier` (43–128 chars). Compute `code_challenge = BASE64URL(SHA256(code_verifier))`.
2. Redirect the user to:  Step 1 — AuthorizeGET https://id.bynefit.com/oauth/authorize ?client_id=site_xxxxxxxxxxxx &redirect_uri=https://yoursite.com/auth/callback &response_type=code &scope=openid+profile+email &state=<random> &code_challenge=<S256-of-verifier> &code_challenge_method=S256
3. The user signs in (if needed) and approves the scopes. Bynefit redirects back to your `redirect_uri` with `?code=…&state=…`. Verify `state` matches what you sent.
4. Exchange the code for tokens:  Step 2 — Token exchangePOST POST https://id.bynefit.com/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code &code=<code> &redirect_uri=<same redirect_uri> &client_id=site_xxxxxxxxxxxx &code_verifier=<the verifier>  Response (JSON): `access_token`, `refresh_token`, `id_token` (JWT), `expires_in`, `token_type=Bearer`, `scope`.
5. Verify the `id_token`: signature against `/oauth/jwks.json`, `iss == https://id.bynefit.com`, `aud == your client_id`, expiry.
6. Call `GET /oauth/userinfo` with `Authorization: Bearer <access_token>` for profile claims (or just trust the id_token claims for openid + profile + email).

## Code for each step

### Make the verifier and challenge

Keep the verifier until the token exchange. In a browser keep it in `sessionStorage`; on a server keep it in the session.

**JavaScript**

```javascript
function base64url(bytes) {
  return btoa(String.fromCharCode(...new Uint8Array(bytes)))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

async function pkcePair() {
  const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
  const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
  return { verifier, challenge: base64url(digest) };
}
```

**Python**

```python
import base64
import hashlib
import secrets

def b64url(raw: bytes) -> str:
    return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()

def pkce_pair():
    verifier = b64url(secrets.token_bytes(32))
    challenge = b64url(hashlib.sha256(verifier.encode()).digest())
    return verifier, challenge
```

**PHP**

```php
function base64url(string $bytes): string
{
    return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '=');
}

function pkce_pair(): array
{
    $verifier  = base64url(random_bytes(32));
    $challenge = base64url(hash('sha256', $verifier, true));
    return [$verifier, $challenge];
}
```

### Send the person to Bynefit

**GET (authorize)**

```http
https://id.bynefit.com/oauth/authorize?response_type=code
  &client_id=site_0123456789abcdef01234567
  &redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fcallback
  &scope=openid%20profile%20email
  &state=RANDOM_STATE
  &code_challenge=CODE_CHALLENGE
  &code_challenge_method=S256
```

### Exchange the code

The code works once and expires after 10 minutes. The request is form-encoded, not JSON.

**cURL**

```bash
curl -X POST https://id.bynefit.com/oauth/token \
  -d grant_type=authorization_code \
  -d code=CODE_FROM_THE_REDIRECT \
  -d client_id=site_0123456789abcdef01234567 \
  -d redirect_uri=https://example.com/auth/callback \
  -d code_verifier=YOUR_CODE_VERIFIER
```

**JavaScript**

```javascript
const res = await fetch("https://id.bynefit.com/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "authorization_code",
    code,
    client_id: "site_0123456789abcdef01234567",
    redirect_uri: "https://example.com/auth/callback",
    code_verifier: verifier
  })
});
const tokens = await res.json();
```

**Python**

```python
import requests

res = requests.post("https://id.bynefit.com/oauth/token", data={
    "grant_type": "authorization_code",
    "code": code,
    "client_id": "site_0123456789abcdef01234567",
    "redirect_uri": "https://example.com/auth/callback",
    "code_verifier": verifier,
})
tokens = res.json()
```

**PHP**

```php
$ch = curl_init('https://id.bynefit.com/oauth/token');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'grant_type' => 'authorization_code',
        'code' => $code,
        'client_id' => 'site_0123456789abcdef01234567',
        'redirect_uri' => 'https://example.com/auth/callback',
        'code_verifier' => $verifier,
    ]),
]);
$tokens = json_decode(curl_exec($ch), true);
```

**200 (JSON)**

```json
{
  "access_token": "eyJhbGciOiJSUzI1NiIs…",
  "token_type": "Bearer",
  "expires_in": 3600,
  "refresh_token": "9f2c…",
  "scope": "openid profile email",
  "id_token": "eyJhbGciOiJSUzI1NiIs…"
}
```

`id_token` is only there when `openid` was granted. The access token lasts an hour and the refresh token 60 days. Errors are JSON with `error` and `error_description`: `400` `invalid_request`, `invalid_grant` or `unsupported_grant_type`, and `401` `invalid_client`.

### Refresh

Trade a refresh token for new tokens before the access token runs out. Each refresh token works once: the answer carries a new one, and the old one stops working at once, so store the new one. A new `id_token` comes back too, so a site signed in weeks ago sees today's name and picture.

**cURL**

```bash
curl -X POST https://id.bynefit.com/oauth/token \
  -d grant_type=refresh_token \
  -d refresh_token=YOUR_REFRESH_TOKEN \
  -d client_id=site_0123456789abcdef01234567
```

**JavaScript**

```javascript
const res = await fetch("https://id.bynefit.com/oauth/token", {
  method: "POST",
  headers: { "Content-Type": "application/x-www-form-urlencoded" },
  body: new URLSearchParams({
    grant_type: "refresh_token",
    refresh_token: tokens.refresh_token,
    client_id: "site_0123456789abcdef01234567"
  })
});
tokens = await res.json();
```

**PHP**

```php
$ch = curl_init('https://id.bynefit.com/oauth/token');
curl_setopt_array($ch, [
    CURLOPT_POST => true,
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_POSTFIELDS => http_build_query([
        'grant_type' => 'refresh_token',
        'refresh_token' => $refreshToken,
        'client_id' => 'site_0123456789abcdef01234567',
    ]),
]);
$tokens = json_decode(curl_exec($ch), true);
```

### Ask who is signed in

**cURL**

```bash
curl https://id.bynefit.com/oauth/userinfo \
  -H "Authorization: Bearer ACCESS_TOKEN"
```

**JavaScript**

```javascript
const res = await fetch("https://id.bynefit.com/oauth/userinfo", {
  headers: { Authorization: "Bearer " + tokens.access_token }
});
const me = await res.json();
```

**PHP**

```php
$ch = curl_init('https://id.bynefit.com/oauth/userinfo');
curl_setopt_array($ch, [
    CURLOPT_RETURNTRANSFER => true,
    CURLOPT_HTTPHEADER => ['Authorization: Bearer ' . $tokens['access_token']],
]);
$me = json_decode(curl_exec($ch), true);
```

**200 (JSON)**

```json
{
  "sub": "bu_0123456789abcdef0123456789abcdef",
  "name": "Sam Rivera",
  "nickname": "samr",
  "picture": "https://bynefit.com/media/avatars/samr.jpg",
  "profile": "https://bynefit.com/p/samr",
  "bio": null,
  "email": "sam@example.com",
  "email_verified": true
}
```

Which claims appear depends on the scopes granted: `profile` gives `name`, `nickname`, `picture`, `profile` and `bio`; `email` gives `email` and `email_verified`. The person chooses, per site, which email, phone number and business a site sees, so the answer can differ between sites and can change. When they share a business with the site, a `business` claim is added; it is absent when they chose not to share one. A `401` `invalid_token` means the token is expired, revoked, or was issued to a different client.

> **Treat `email_verified` with care for older accounts.** Accounts made before 11 May 2026 report `true` without having confirmed the address. Do not use it alone as proof that someone controls an email.

## Client rules

- A client id looks like `site_` and 24 hex characters.
- A client can have up to 10 return addresses. Each is `https` (or `http://localhost` for development) with no `#` fragment, and matched exactly.
- A native app returns through an `https` link on your own domain. A custom scheme is your own domain written backwards, such as `vip.example.app://callback`. A plain word such as `myapp://` is refused, and Bynefit staff approve each scheme for you first, then it is matched exactly. Apps must use PKCE with S256, and an authorization code is single-use and expires after 10 minutes.  (By approval)
- The client name is shown on the consent screen. Names that imitate Bynefit or sound like a sign-in prompt ("Sign in", "Login", "Account") are refused.
- Creator status and earnings (`creator.read`, `earnings.read`) can be asked for on a client, and Bynefit reviews each use before it works.  (By approval)

## Sign in on your own page

A sign-in sheet that opens over your page, without sending people away, is available to the websites listed on the client. Everything the person types is inside a frame served by the sign-in host, which your page's script cannot read. On a Bynefit-hosted site the `auth` kit module draws the button and runs the whole flow; elsewhere you can still use the redirect flow above. Allow the sign-in host in your `frame-src` and `connect-src` if your site sets a Content Security Policy, and keep scripts on the page trusted, because tokens are kept in the browser. If the frame cannot load, the sheet offers to continue in a new window.

## Silent sign-in

For returning users already signed into Bynefit, pass `prompt=none` on the authorize call. The server either redirects back with a code immediately (no UI shown) or returns one of these errors so your client can fall back to manual sign-in:

| Error | Meaning |
| --- | --- |
| `login_required` | User has no active Bynefit session — show the manual button. |
| `consent_required` | User is signed in but hasn't yet approved your scopes — start the normal flow. |

## Scopes

| Scope | Grants |
| --- | --- |
| `openid` | Required. Identifies this as an OIDC request and authorizes an id_token. |
| `profile` | Name, username, bio, and profile picture (`picture`, an absolute https URL). |
| `email` | Email address + verification status. |
| `phone` | Phone number + verification status. *Available to first-party clients only at this time.* |
| `creator.read` | The person's creator status, milestones, tiers, activity and content stats, through the creator hub endpoints below. *Needs staff approval for the client.* |
| `earnings.read` | The person's creator earnings, bonuses, payouts and ambassador earnings, through the creator hub endpoints below. *Needs staff approval for the client.* |

### Scopes that need approval

`creator.read` and `earnings.read` are not open by default. A manager or admin of the client's Space asks for them on the client's page at [/dash/sites/oauth](https://bynefit.com/dash/sites/oauth); Bynefit staff are told and approve or decline each one. Until a scope is approved it is left out of the grant: the authorization still succeeds with the scopes the client may use, the token response's `scope` omits it and the person is never shown it on the consent screen (the request fails with `invalid_scope` only when no allowed scope remains). Check the `scope` in the token response before you call an API that needs one. Once approved, the person sees it on the consent screen as "Your creator status, milestones, tiers, and content stats" or "Your creator earnings and payout status", and can still decline.

Additional scopes (`teams:read`, `wallet:read`, `tasks:read`, `purchases:read`, `feeds:read`) exist for first-party Bynefit apps and aren't open to site-registered clients. Reach out if your use case needs one.

## What a signed-in token can call

Signing in gives your site an access token. Besides the userinfo endpoint, it is the bearer token for two Bynefit APIs, each gated by what the person approved:

| API | Needs | What it does |
| --- | --- | --- |
| [Client portal API](https://bynefit.com/docs/client-portal-api) | A sign-in client owned by a practice Space; no extra scope. The person must be that practice's customer, and some routes need the practice add-on (see the page) | The signed-in person reads their own engagements, documents and messages on a practice site, and a practice manager can run the pipeline from its admin page. Calls look in the Space that owns the client. |
| Creator hub API, `/api/creator/web/*` | `creator.read` or `earnings.read` on the client | The creator's own numbers. With `creator.read`: `activity`, `analytics`, `milestones`, `tiers`, `top-content`. With `earnings.read`: `earnings-breakdown`, `bonuses`, `payouts`, `ambassador-earnings`. `overview` returns the sections the token's scopes allow. |

Call the creator hub with `Authorization: Bearer`. All its endpoints are `GET`, answer only about the signed-in person, and send no cross-origin permission except to `https://creators.bynefit.com` and any origin Bynefit has added for a partner hub, so a call from another website's browser is refused (`403` on the preflight); call it from your own server if you need it elsewhere. A token without the scope answers `403` `insufficient_scope`; an expired or revoked token, or one from a first-party Bynefit app, answers `401` `invalid_token`.

## Subject identifier (`sub`)

The `sub` claim is a stable, opaque, per-issuer user identifier. Treat it as your primary key when storing per-user data on your side — never key on email (which can change) or display name. Two different Bynefit users will never share a `sub`; the same user signing into two different clients gets the same `sub`.

## Errors

Returned as standard OAuth error params on the redirect_uri or as JSON from the token endpoint:

| Error code | When |
| --- | --- |
| `invalid_request` | Missing required parameter, bad redirect_uri, or missing PKCE. |
| `invalid_client` | client_id is unknown, inactive, or doesn't match the redirect_uri. |
| `invalid_grant` | code expired (10-minute TTL), already used, or code_verifier doesn't match the original challenge. |
| `invalid_scope` | Requested a scope not allowed for this client. |
| `access_denied` | User clicked Deny on the consent screen. |
| `login_required` / `consent_required` | Silent (`prompt=none`) flow couldn't complete without UI. |

## Revocation

Users can revoke your client's access at [/dash/me/apps](https://bynefit.com/dash/me/apps). When revoked, the consent record is deleted and every outstanding access + refresh token is marked revoked. Your next call to `/oauth/token` with a refresh token will return `invalid_grant`; access tokens stop working immediately.

## The browser kit (for static sites)

If your site is hosted by Bynefit (`*.site.bynefit.com` or a verified custom domain), the `auth` module of the Bynefit kit wraps the entire flow into one config block plus a couple of HTML attributes — no PKCE plumbing required. See the [site-config guide](https://bynefit.com/help/site-config#auth).

## CORS

`/oauth/token`, `/oauth/userinfo`, and `/oauth/jwks.json` respond with `Access-Control-Allow-Origin: *` so browser-based clients on any origin can call them. Credentials are never echoed — the flow's security comes from PKCE plus the redirect-URI allowlist on your client.

---

Source: https://bynefit.com/docs/oauth
Checked against the live API on October 10, 2026.
