> ## Documentation Index
> Fetch the complete documentation index at: https://docs.ofauth.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Tokens and refresh

> Exchange codes, refresh access tokens, revoke sessions, and read discovery metadata

## Token endpoint

```http theme={null}
POST https://api.ofauth.com/v3/link/token
```

| Requirement | Value |
| - | - |
| Client authentication | `client_secret_basic` only. `Authorization: Basic base64(client_id:client_secret)`. The client ID selects the environment. |
| Body encoding | `application/x-www-form-urlencoded`. JSON is rejected. |
| Grants | `authorization_code` and `refresh_token`. Any other grant type returns `unsupported_grant_type`. Accounts migrated from V2 use `refresh_token` with the tokens from your [account bundle](/guides/migrate-v2-to-v3#switch-your-accounts-to-v3). |
| `resource` | Not needed. If your OAuth library sends it, it must be `https://api.ofauth.com/v3`, or the request fails with `invalid_target`. |

Each form field may appear once. Unknown fields are rejected with `invalid_request`.

## Exchange the authorization code

| Field | Requirement |
| - | - |
| `grant_type` | `authorization_code` |
| `code` | The code from the callback. |
| `redirect_uri` | The exact `redirect_uri` from the authorization request. |
| `code_verifier` | The PKCE verifier for the `code_challenge` you sent. |
| `client_id` | Optional. If present, it must match the Basic credentials. |

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.ofauth.com/v3/link/token \
    -u "$OFAUTH_CLIENT_ID:$OFAUTH_CLIENT_SECRET" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data-urlencode "grant_type=authorization_code" \
    --data-urlencode "code=$CODE" \
    --data-urlencode "redirect_uri=https://developer.example.com/oauth/ofauth/callback" \
    --data-urlencode "code_verifier=$VERIFIER"
  ```

  ```javascript Node.js theme={null}
  const credentials = Buffer.from(
    `${encodeURIComponent(process.env.OFAUTH_CLIENT_ID)}:${encodeURIComponent(process.env.OFAUTH_CLIENT_SECRET)}`
  ).toString("base64")

  const response = await fetch("https://api.ofauth.com/v3/link/token", {
    method: "POST",
    headers: {
      authorization: `Basic ${credentials}`,
      "content-type": "application/x-www-form-urlencoded"
    },
    body: new URLSearchParams({
      grant_type: "authorization_code",
      code,
      redirect_uri: "https://developer.example.com/oauth/ofauth/callback",
      code_verifier: pending.codeVerifier
    })
  })

  if (!response.ok) {
    const error = await response.json()
    throw new Error(`${error.error}: ${error.error_description}`)
  }

  const tokens = await response.json()
  ```
</CodeGroup>

### Token response

Every grant returns the same shape.

```json theme={null}
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ii4uLiJ9...",
  "token_type": "Bearer",
  "expires_in": 600,
  "refresh_token": "...",
  "scope": "profile:read messages:read offline_access",
  "authorization_id": "external_live_example123",
  "linked_accounts": {
    "data": [
      { "id": "conn_example123", "provider": "onlyfans" }
    ],
    "has_more": false,
    "next_cursor": null
  }
}
```

| Field | Description |
| - | - |
| `access_token` | RS256-signed JWT (RFC 9068). Valid for 10 minutes. Its audience is `https://api.ofauth.com/v3`. Treat it as opaque. |
| `token_type` | Always `Bearer`. |
| `expires_in` | Seconds until the access token expires. |
| `refresh_token` | Present only when `offline_access` was granted. Valid for 90 days from its issue and rotated on every use. |
| `scope` | The granted scopes. May be narrower than requested. |
| `authorization_id` | The authorization this token belongs to. Stable across refreshes and re-consent. Map it to your user. |
| `linked_accounts` | The first page (up to 100) of this authorization's linked accounts. Send an `id` as `x-linked-account-id`. When `has_more` is `true`, fetch the rest from [`GET /v3/linked-accounts`](/v3/linked-accounts#list-linked-accounts) with `cursor=next_cursor`. |

Store the refresh token per user on your backend. Do not persist access tokens beyond their lifetime.

## Refresh the access token

| Field | Requirement |
| - | - |
| `grant_type` | `refresh_token` |
| `refresh_token` | The most recent refresh token for this session. |
| `scope` | Optional. A subset of the granted scopes to narrow the new access token. |

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.ofauth.com/v3/link/token \
    -u "$OFAUTH_CLIENT_ID:$OFAUTH_CLIENT_SECRET" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data-urlencode "grant_type=refresh_token" \
    --data-urlencode "refresh_token=$REFRESH_TOKEN"
  ```

  ```javascript Node.js theme={null}
  async function refreshTokens(userId) {
    const stored = await loadRefreshToken(userId)

    const response = await fetch("https://api.ofauth.com/v3/link/token", {
      method: "POST",
      headers: {
        authorization: `Basic ${credentials}`,
        "content-type": "application/x-www-form-urlencoded"
      },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: stored
      })
    })

    if (response.status === 400) {
      const error = await response.json()
      if (error.error === "invalid_grant") {
        // Session revoked or expired. The user must authorize again.
        await markReauthorizationRequired(userId)
      }
      throw new Error(error.error)
    }

    const tokens = await response.json()
    // Always persist the new refresh token. The old one is now invalid.
    await saveRefreshToken(userId, tokens.refresh_token)
    return tokens
  }
  ```
</CodeGroup>

The response has the same shape as the code exchange and contains a new `refresh_token`.

### Rotation and replay

* Every refresh returns a new refresh token and invalidates the one you sent.
* Presenting an already-used refresh token is treated as replay. OFAuth revokes the whole authorization that token belongs to and sends `authorization.revoked` with `reason: "refresh_token_replay"`. Other users are not affected.
* After a replay, every refresh for that authorization fails with `invalid_grant`. The user must authorize again.
* Serialize refreshes per session. Two concurrent refreshes with the same token look like replay.

## Sessions

Each successful code exchange starts a session: a refresh token and the tokens rotated from it. Running authorization again for the same user starts another session in the same authorization. If you lose a refresh token, send the user through authorization again; they see the accounts and scopes they already approved.

## Revoke

```http theme={null}
POST https://api.ofauth.com/v3/link/revoke
```

RFC 7009. Authenticate with `client_secret_basic` and send a form body.

| Field | Requirement |
| - | - |
| `token` | A refresh token, or an access token issued with `offline_access`. |
| `token_type_hint` | Optional. `refresh_token` or `access_token`. |
| `client_id` | Optional. Must match the Basic credentials if present. |

<CodeGroup>
  ```bash curl theme={null}
  curl -X POST https://api.ofauth.com/v3/link/revoke \
    -u "$OFAUTH_CLIENT_ID:$OFAUTH_CLIENT_SECRET" \
    -H "Content-Type: application/x-www-form-urlencoded" \
    --data-urlencode "token=$REFRESH_TOKEN" \
    --data-urlencode "token_type_hint=refresh_token"
  ```

  ```javascript Node.js theme={null}
  await fetch("https://api.ofauth.com/v3/link/revoke", {
    method: "POST",
    headers: {
      authorization: `Basic ${credentials}`,
      "content-type": "application/x-www-form-urlencoded"
    },
    body: new URLSearchParams({
      token: stored,
      token_type_hint: "refresh_token"
    })
  })
  ```
</CodeGroup>

A successful revoke returns `200` with an empty body, including for tokens that are already invalid.

Revoke ends the session the token belongs to. Other sessions of the same authorization keep working. Revoking the last active session ends the authorization and sends `authorization.revoked` with `reason: "oauth_revocation"`. The user must then authorize again before you can call Access for them.

To remove one account but keep the rest of the authorization, use [`DELETE /v3/linked-accounts/{id}`](/v3/linked-accounts#remove-a-linked-account).

## Discovery

```http theme={null}
GET https://auth.ofauth.com/.well-known/oauth-authorization-server/link
```

Returns RFC 8414 metadata for the issuer `https://auth.ofauth.com/link`:

| Field | Value |
| - | - |
| `issuer` | `https://auth.ofauth.com/link` |
| `authorization_endpoint` | `https://auth.ofauth.com/link/authorize` |
| `token_endpoint` | `https://api.ofauth.com/v3/link/token` |
| `revocation_endpoint` | `https://api.ofauth.com/v3/link/revoke` |
| `jwks_uri` | `https://auth.ofauth.com/.well-known/jwks.json` |
| `response_types_supported` | `["code"]` |
| `grant_types_supported` | `["authorization_code", "refresh_token"]` |
| `code_challenge_methods_supported` | `["S256"]` |
| `token_endpoint_auth_methods_supported` | `["client_secret_basic"]` |
| `revocation_endpoint_auth_methods_supported` | `["client_secret_basic"]` |
| `scopes_supported` | Every Access scope, plus `offline_access`. |

Use it to configure a generic OAuth client library.

## Token endpoint errors

Errors follow RFC 6749.

```json theme={null}
{
  "error": "invalid_grant",
  "error_description": "request rejected"
}
```

| `error` | HTTP | Meaning |
| - | - | - |
| `invalid_request` | 400 | Missing or malformed field, wrong encoding, duplicate or unknown parameter. |
| `unsupported_grant_type` | 400 | `grant_type` is not `authorization_code` or `refresh_token`. |
| `invalid_client` | 401 | Bad or missing Basic credentials, or the client is unavailable. Includes `WWW-Authenticate: Basic realm="link", charset="UTF-8"`. |
| `invalid_grant` | 400 | Code expired, used, or mismatched; refresh token expired, used, or revoked; PKCE verifier mismatch. |
| `invalid_scope` | 400 | Requested scope is unknown or exceeds the grant. |
| `invalid_target` | 400 | `resource` is not `https://api.ofauth.com/v3`, or the client is in the wrong environment for the grant. |
| `rate_limited` | 429 | Too many requests, or too many failed client authentications. Wait for `Retry-After` seconds. |
| `server_error` | 500 | Retry with backoff. |

When the integration itself blocks the request, the body also carries `reason` and `recovery`:

```json theme={null}
{
  "error": "invalid_grant",
  "error_description": "request rejected",
  "reason": "developer_plan_unavailable",
  "recovery": { "action": "contact_integration_owner" }
}
```

`reason` is one of `client_deleted`, `developer_plan_unavailable`, or `security_suspended`.

Token responses are sent with `Cache-Control: no-store`.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.