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

# Client-Facing OAuth Grant Flow

> Send a user to Fluz to approve your scopes, and handle the authorization code that comes back — including the state validation that makes the flow safe.

This is the step where a real person decides whether to let your application touch their Fluz account. You redirect them to Fluz, they approve, and Fluz redirects them back to you with a short-lived authorization code.

Everything before this page is configuration. Everything after it is token handling. This is the only step your user sees.

## Where this sits

```mermaid theme={null}
sequenceDiagram
    participant U as User
    participant Y as Your server
    participant F as Fluz

    U->>Y: Clicks "Connect Fluz"
    Y->>Y: Generate and store `state`
    Y->>U: 302 to /authorize
    U->>F: Loads consent screen
    F->>U: Sign in, 2FA, review scopes
    U->>F: Approves
    F->>U: 302 to your redirect_uri (code, state)
    U->>Y: Hits your callback
    Y->>Y: Validate `state`
    Y->>F: POST /token/exchange (code)
    F->>Y: accessToken, refreshToken, scope
```

<Info>
  Note what Fluz absorbs in the middle of that diagram: account creation, sign-in, two-factor authentication, and identity verification if the user hasn't been verified yet. You don't build any of it, and you never see the credentials.
</Info>

## Before you start

<Steps>
  <Step title="Your app is configured">
    Scope ceiling set on the **Permissions** tab, redirect URI registered on the **OAuth** tab, `client_id` and `client_secret` in your secret store. See [Configure OAuth App](/configure-o-auth-app).
  </Step>

  <Step title="Your callback route exists and can reach your session store">
    It needs to read `state` and compare it against something you persisted before the redirect.
  </Step>

  <Step title="You know which scopes this flow needs">
    See [Authentication](/concepts/authentication) for the full list. Request the minimum — the consent screen is your highest-drop-off step and its length comes from this list.
  </Step>
</Steps>

***

## Step 1 — Build the authorize URL

Direct the user to the `/authorize` endpoint with the following query parameters.

| Parameter       | Required                                        | Description                                                                                                   |
| :-------------- | :---------------------------------------------- | :------------------------------------------------------------------------------------------------------------ |
| `response_type` | Yes                                             | Always `code` for a permissions request.                                                                      |
| `client_id`     | Yes                                             | Your app's OAuth client ID, from the **Overview** tab.                                                        |
| `redirect_uri`  | Yes                                             | A URI already registered on your app. Must be reused byte-for-byte at exchange.                               |
| `scopes`        | Yes                                             | Space-delimited list of scopes, URL-encoded. Must be a subset of your app's ceiling.                          |
| `state`         | Technically optional — **treat it as required** | An unguessable value returned to you unmodified. See [Step 2](#step-2-%E2%80%94-protect-the-flow-with-state). |

<Warning>
  The parameter is **`scopes`**, plural — not `scope` as in the base OAuth 2.0 specification. If you're using a generic OAuth client library, this is the field you'll have to override.
</Warning>

### Environments

| Environment | Authorize endpoint                                                                            |
| :---------- | :-------------------------------------------------------------------------------------------- |
| Staging     | `https://uni.staging.fluzapp.com/authorize`                                                   |
| Production  | Issued during production onboarding — see [Deploying to Production](/deploying-to-production) |

### Encoding rules

Spaces between scopes must be encoded as `%20`. URL-encode the `redirect_uri` value as well; build the query string with your language's URL encoder rather than string concatenation, and these take care of themselves.

A complete staging example:

```text theme={null}
https://uni.staging.fluzapp.com/authorize?response_type=code&client_id=dab5c80e-0321-4c3a-988a-ffedfd64d8db&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Ffinalize&scopes=MAKE_DEPOSIT%20LIST_PAYMENT%20MAKE_WITHDRAW%20REVEAL_VIRTUALCARD&state=8f14e45fceea167a5a36dedd4bea2543
```

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'crypto';

  const AUTHORIZE_URL = 'https://uni.staging.fluzapp.com/authorize';
  const REDIRECT_URI = 'https://app.example.com/oauth/finalize'; // one canonical constant

  export function buildAuthorizeUrl(session) {
    const state = crypto.randomBytes(32).toString('hex');
    session.oauthState = state; // persist server-side, bound to this session

    const url = new URL(AUTHORIZE_URL);
    url.searchParams.set('response_type', 'code');
    url.searchParams.set('client_id', process.env.FLUZ_CLIENT_ID);
    url.searchParams.set('redirect_uri', REDIRECT_URI);
    url.searchParams.set('scopes', ['MAKE_WITHDRAW', 'LIST_PAYMENT'].join(' '));
    url.searchParams.set('state', state);

    return url.toString();
  }
  ```

  ```python Python theme={null}
  import os
  import secrets
  from urllib.parse import urlencode

  AUTHORIZE_URL = "https://uni.staging.fluzapp.com/authorize"
  REDIRECT_URI = "https://app.example.com/oauth/finalize"  # one canonical constant


  def build_authorize_url(session):
      state = secrets.token_hex(32)
      session["oauth_state"] = state  # persist server-side, bound to this session

      params = {
          "response_type": "code",
          "client_id": os.environ["FLUZ_CLIENT_ID"],
          "redirect_uri": REDIRECT_URI,
          "scopes": " ".join(["MAKE_WITHDRAW", "LIST_PAYMENT"]),
          "state": state,
      }
      return f"{AUTHORIZE_URL}?{urlencode(params)}"
  ```

  ```go Go theme={null}
  package fluz

  import (
      "crypto/rand"
      "encoding/hex"
      "net/url"
      "os"
      "strings"
  )

  const (
      authorizeURL = "https://uni.staging.fluzapp.com/authorize"
      redirectURI  = "https://app.example.com/oauth/finalize"
  )

  func BuildAuthorizeURL() (authURL string, state string, err error) {
      b := make([]byte, 32)
      if _, err = rand.Read(b); err != nil {
          return "", "", err
      }
      state = hex.EncodeToString(b) // persist server-side, bound to this session

      u, err := url.Parse(authorizeURL)
      if err != nil {
          return "", "", err
      }

      q := u.Query()
      q.Set("response_type", "code")
      q.Set("client_id", os.Getenv("FLUZ_CLIENT_ID"))
      q.Set("redirect_uri", redirectURI)
      q.Set("scopes", strings.Join([]string{"MAKE_WITHDRAW", "LIST_PAYMENT"}, " "))
      q.Set("state", state)
      u.RawQuery = q.Encode()

      return u.String(), state, nil
  }
  ```
</CodeGroup>

### What the user sees

![Sample OAuth permissions page](https://storage.googleapis.com/fluz-fluz-file-uploads-staging-wlfprelricuyxowb/assets/oauth-widget-permissions.png)

Your app name, avatar, and description come straight from the **Overview** tab, and the permission lines are your selected scopes grouped under readable headers. If this screen looks wrong, the fix is on [Configure OAuth App](/configure-o-auth-app), not in your code.

***

## Step 2 — Protect the flow with `state`

The reference table calls `state` optional. In a redirect-based authorization flow it is your only defense against having someone else's authorization code planted in your user's session, so build it in from the first commit rather than adding it later.

<Steps>
  <Step title="Generate an unguessable value">
    At least 128 bits from a cryptographically secure source. Not a timestamp, not a user ID, not a counter.
  </Step>

  <Step title="Store it server-side, bound to the browser session">
    Session store, signed cookie, or short-TTL cache keyed to the session. Not in a global.
  </Step>

  <Step title="Compare on the way back, and reject on mismatch">
    Missing, unrecognized, or already-used `state` means abandon the request — do not exchange the code. Use a constant-time comparison.
  </Step>

  <Step title="Consume it">
    Delete it after a successful match so the same callback can't be replayed.
  </Step>
</Steps>

<Note>
  `state` travels through the user's browser. It's fine to use it to carry a lookup key — which user, which flow, which page to return to — but never put anything sensitive or trusted in the value itself.
</Note>

***

## Step 3 — Handle the callback

On approval, Fluz redirects the user to your `redirect_uri` with:

| Parameter | Description                                                                                               |
| :-------- | :-------------------------------------------------------------------------------------------------------- |
| `code`    | The authorization code tied to this user's scope grant. Single-use, short-lived. Exchange it server-side. |
| `state`   | The exact value you sent, unmodified.                                                                     |

If the request was misconfigured, the redirect carries an error message describing what didn't match.

<CodeGroup>
  ```javascript Node.js theme={null}
  import crypto from 'crypto';

  app.get('/oauth/finalize', async (req, res) => {
    const { code, state } = req.query;
    const expected = req.session.oauthState;
    delete req.session.oauthState; // single use, regardless of outcome

    if (!code || !state || !expected) {
      return res.status(400).send('Incomplete authorization response');
    }

    const a = Buffer.from(String(state));
    const b = Buffer.from(String(expected));
    if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
      return res.status(400).send('State mismatch — request abandoned');
    }

    // Exchange server-side. Never send the code back to the browser.
    const tokens = await exchangeAuthorizationCode(code);
    await persistTokens(req.session.userId, tokens);

    // Trust the returned scope array, not what you requested.
    res.redirect('/settings/connections');
  });
  ```

  ```python Python theme={null}
  import hmac
  from flask import request, session, abort, redirect


  @app.route("/oauth/finalize")
  def oauth_finalize():
      code = request.args.get("code")
      state = request.args.get("state")
      expected = session.pop("oauth_state", None)  # single use

      if not code or not state or not expected:
          abort(400, "Incomplete authorization response")

      if not hmac.compare_digest(state, expected):
          abort(400, "State mismatch - request abandoned")

      # Exchange server-side. Never send the code back to the browser.
      tokens = exchange_authorization_code(code)
      persist_tokens(session["user_id"], tokens)

      # Trust the returned scope array, not what you requested.
      return redirect("/settings/connections")
  ```
</CodeGroup>

<Warning>
  Exchange the code **immediately, once, from your server**. It is single-use and short-lived. Make your callback route idempotent — a user refreshing the page, a link prefetcher, or a browser retry will hit it twice, and the second attempt must not corrupt state or surface an error to the user who already succeeded.
</Warning>

Next: [Exchange an OAuth authorization code](/exchange-an-o-auth-authorization-code).

***

## Step 4 — Reconcile what you actually got

The exchange response includes the scope array the user approved. **That array, not your request, is the truth about what your integration can do.**

A scope you requested can be missing because the user declined it, or because it isn't enabled on your app's Permissions tab — in which case it was silently dropped rather than rejected. Either way the flow completes successfully and your API calls fail later.

Read the returned scopes, persist them alongside the tokens, and gate your features on them. If something essential is missing, tell the user plainly and offer to re-run the flow.

***

## Designing the moment

The consent screen converts far better when the user understands why they're seeing it.

* **Explain before you redirect.** One sentence on your own page — "Connect your Fluz account so we can send your payouts" — outperforms dropping someone cold onto a permissions screen.
* **Trigger it in context.** At the point of first payout or first card, not buried in account settings.
* **Full-page redirect over a popup.** Popups get blocked, and the flow includes 2FA and possibly identity verification, which is uncomfortable in a small window. If you need to stay in-page, use an [embedded widget](/developers/widgets) instead, which is built for exactly that.
* **Handle the return trip.** Land the user where they were, with the thing they were trying to do now working. `state` is how you know where that was.
* **Have a re-authorization path.** Refresh tokens expire and users revoke access. Build the "reconnect" flow at the same time as the connect flow, not after the first support ticket.
* **Consider skipping it.** If your users don't already have Fluz accounts, a widget handles registration, verification, and consent in one hosted flow with no redirect. See [Embedded Widgets](/developers/widgets).

***

## Troubleshooting

| Symptom                                                 | Almost always                                                                                     |
| :------------------------------------------------------ | :------------------------------------------------------------------------------------------------ |
| `/authorize` errors instead of rendering consent        | `redirect_uri` isn't registered on the app, or `client_id` is from a different app or environment |
| Consent screen shows fewer permissions than requested   | Those scopes aren't checked on the Permissions tab — they were dropped, not rejected              |
| Scopes appear to be ignored entirely                    | Parameter was named `scope`; it must be `scopes`                                                  |
| Scopes garbled or truncated                             | Delimiter not URL-encoded — spaces must be `%20`                                                  |
| Callback arrives with no `state`                        | It wasn't sent on the authorize request                                                           |
| `state` never matches                                   | Session isn't sticky across the redirect, or it's being stored per-process behind a load balancer |
| Exchange fails right after a clean consent              | `redirect_uri` at exchange doesn't byte-match the one used at authorize                           |
| Second callback hit throws an error at the user         | Code already consumed — make the route idempotent                                                 |
| Consent screen shows a placeholder name or blank avatar | **Overview** tab was never filled in                                                              |

***

## Next steps

<CardGroup cols={2}>
  <Card title="Exchange an authorization code" icon="arrow-left-right" href="/exchange-an-o-auth-authorization-code">
    Turn the code into an access token and refresh token.
  </Card>

  <Card title="Refresh an access token" icon="refresh-cw" href="/refresh-o-auth-access-token">
    Stay connected without sending the user back through consent.
  </Card>

  <Card title="Configure OAuth app" icon="sliders" href="/configure-o-auth-app">
    Fix anything the consent screen got wrong.
  </Card>

  <Card title="Embedded widgets" icon="layout-template" href="/developers/widgets">
    Skip the redirect entirely with a hosted in-page flow.
  </Card>
</CardGroup>
