<!-- llms.txt: https://workos.com/llms.txt -->
<!-- feedback.md: https://workos.com/docs/feedback.md -->

# Securing the authorization flow

## Overview

A Pipes authorization flow begins with a URL that takes the user to the
provider's OAuth consent screen. That URL is tailored to a specific user and,
optionally, an organization, but whoever opens that URL and signs in at the provider decides
which provider account gets connected. If the URL ends up with someone else,
their provider account could be connected to the user who started the
authorization.

This guide only applies if you start authorizations from your own UI with the
API. The [Pipes widget](https://workos.com/docs/widgets/pipes) manages the handoff for you. If you're
building your own UI, Pipes offers two ways to protect the authorization:

1. **The confirmation screen** shows whose account the provider account will be
   connected to before the user reaches the provider. This is the default.
2. **Session binding** lets your server send a `code_challenge` and keep the
   matching `code_verifier` in the user's session. Pipes only saves the
   connected account when your server finishes the authorization with that
   verifier. This uses the same verifier-and-challenge pattern as PKCE. It's
   stronger than the confirmation screen and replaces it, so your users see
   one less screen.

## The confirmation screen

When enabled, Pipes shows a confirmation screen before sending the user to the
provider. The screen says whose account the provider account is about to be
connected to: the user's email address, your project, and the organization for
organization-owned providers. A user who was handed another user's link can see
the mismatch and cancel. If they cancel, the provider is never contacted. When
the authorization included a `return_to`, Pipes redirects there with
`error=access_denied`; otherwise, Pipes renders its own canceled page.

![GitHub account connection confirmation in WorkOS Pipes](https://images.workoscdn.com/images/61b46ea8-df30-4e29-969e-70569ebb16aa.png?auto=format\&fit=clip\&q=50)

## Session binding

Alternatively, you can send a `code_challenge` when you start the
authorization, and Pipes holds the result instead of saving it when the user
returns from the provider:

1. Your server creates a random `code_verifier`, keeps it in the user's session,
   and sends a hash of it, the `code_challenge`, to the authorize endpoint.
2. The user approves access at the provider. Pipes holds the result and sends
   the user to your `return_to` URL with a one-time `connect_code`.
3. Your server sends the `connect_code` and the `code_verifier` to the finish
   endpoint, and Pipes saves the connected account.

The browser only ever sees the `connect_code`, and the `code_verifier` never
leaves your server. Neither one is enough to finish the authorization alone.
Unlike the confirmation screen, this protection doesn't depend on the user
noticing that an authorization URL belongs to someone else. Authorizations that
send a `code_challenge` skip the confirmation screen: session binding replaces
it, so opting in means one less screen for the user.

## Register your return URL

Pipes only sends the `connect_code` to a `return_to` URL registered for an
application in the same environment. In the
[Applications](https://dashboard.workos.com/environment/applications) section
of the Dashboard, open your application and go to the **Redirects** tab.

- `return_to` must exactly match a registered redirect URI. A URI that only
  matches a wildcard entry is rejected.
- Use HTTPS. HTTP is allowed only for loopback addresses such as `localhost`,
  which match on any port.
- Don't include a query string or fragment. Pipes adds `connect_code` and
  `state` as query parameters.

## Start the authorization

The examples show two route handlers from a hypothetical Express app, not a
complete application. They assume your sign-in and CSRF middleware already
protect the start route and your server-side session contains `userId`. Use a
secure, HTTP-only session cookie with `SameSite=Lax`, so the browser sends it
when the user returns from the provider. These excerpts keep one pending
authorization per session.

Generate a new verifier for each authorization: 32 random bytes, base64url
encoded. Hash it with `sha256` and base64url encode the result. Send that
challenge with `code_challenge_method: 'S256'` and your registered return URL.

:::code-group

```js language="js"
import crypto from 'node:crypto';

app.post('/integrations/:provider/authorize', async (req, res) => {
  const codeVerifier = crypto.randomBytes(32).toString('base64url');
  const codeChallenge = crypto
    .createHash('sha256')
    .update(codeVerifier)
    .digest('base64url');

  try {
    const response = await fetch(
      `https://api.workos.com/data-integrations/${encodeURIComponent(req.params.provider)}/authorize`,
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.WORKOS_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          user_id: req.session.userId,
          return_to: 'https://example.com/integrations/callback',
          code_challenge: codeChallenge,
          code_challenge_method: 'S256',
        }),
      },
    );

    if (!response.ok) {
      return res.status(502).send("Couldn't start the authorization.");
    }

    const authorization = await response.json();

    // Don't redirect unless WorkOS confirms it will wait for your server.
    if (
      authorization.completion !== 'finish_required' ||
      !authorization.state
    ) {
      return res.status(502).send("Couldn't start the authorization.");
    }

    req.session.pendingAuthorization = {
      state: authorization.state,
      codeVerifier,
    };

    res.redirect(303, authorization.url);
  } catch {
    res.status(502).send("Couldn't start the authorization.");
  }
});
```

:::

Before redirecting, check that Pipes returned `completion: 'finish_required'`
and a `state` value. Without that acknowledgment, Pipes could save the connected
account at the provider callback without waiting for your server's proof. Store the
verifier and returned state in the user's session before sending them to the
authorization URL.

## Finish the authorization

Pipes returns the user to your registered URL with a `connect_code` or an
`error`. Before handling either result, compare the returned `state` with the
state in the user's session. If it's missing or doesn't match, leave the
pending authorization alone: an unrelated request should not cancel an
authorization in progress.

Once `state` matches, clear the pending authorization. If the user canceled or
the provider refused access, redirect to your application's error state.
Otherwise, finish immediately with the verifier from the session. Use an API
key for the same environment that started the authorization.

:::code-group

```js language="js"
app.get('/integrations/callback', async (req, res) => {
  res.set({ 'Cache-Control': 'no-store', 'Referrer-Policy': 'no-referrer' });

  const { connect_code: connectCode, error, state } = req.query;
  const pending = req.session.pendingAuthorization;

  // An unrelated request must not clear the authorization in progress.
  if (!pending || typeof state !== 'string' || state !== pending.state) {
    return res.redirect(303, '/integrations?authorization=failed');
  }

  delete req.session.pendingAuthorization;

  if (typeof error === 'string') {
    return res.redirect(303, '/integrations?authorization=denied');
  }

  if (typeof connectCode !== 'string') {
    return res.redirect(303, '/integrations?authorization=failed');
  }

  try {
    const response = await fetch(
      'https://api.workos.com/data-integrations/finish',
      {
        method: 'POST',
        headers: {
          Authorization: `Bearer ${process.env.WORKOS_API_KEY}`,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          connect_code: connectCode,
          code_verifier: pending.codeVerifier,
          state,
        }),
      },
    );

    if (!response.ok) {
      return res.redirect(303, '/integrations?authorization=failed');
    }

    const { connected_account: connectedAccount } = await response.json();

    res.redirect(
      303,
      `/integrations?connected=${encodeURIComponent(connectedAccount.id)}`,
    );
  } catch {
    // Don't retry, even on a network error. Check for a connected account or start over.
    res.redirect(303, '/integrations?authorization=failed');
  }
});
```

:::

For the callback route:

- Redirect to an application URL that doesn't contain `connect_code`, even
  after an API or network failure.
- Use `Cache-Control: no-store` to prevent caching of the callback response.
- Use `Referrer-Policy: no-referrer` to prevent the callback URL from being
  forwarded as a referrer.
- Treat `connect_code` as sensitive information. Keep it out of application
  logs and analytics.

Pipes holds the result for about 90 seconds after the user returns from the
provider. After that, the `connect_code` no longer works and the user needs to
start over. On success, `POST /data-integrations/finish` returns the connected
account in the `connected_account` response field.

## Errors and retries

A held authorization finishes at most once. Treat every finish call as the only
attempt:

- An incorrect `code_verifier` uses up the `connect_code`. Calling finish again
  with the right verifier fails.
- A `connect_code` that's already been used, has expired, or belongs to another
  environment fails with `invalid_connect_code`.
- Don't retry a failed finish call, including after a network error. Check
  whether the user's connected account exists, or start a new authorization.

Only act on a returned `error` when `state` matches the pending authorization
in the user's session. If the error response has no matching state, the callback
uses the generic failure path and leaves the pending authorization alone.

| Code                             | Endpoint  | Meaning                                                                                                                       |
| -------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------- |
| `connect_finish_unavailable`     | authorize | Session-bound authorization isn't available for your environment yet.                                                         |
| `return_to_requires_exact_match` | authorize | `return_to` only matches a wildcard redirect URI. Register the exact URL.                                                     |
| `invalid_return_to`              | authorize | `return_to` is missing, doesn't exactly match a registered redirect URI, isn't HTTPS, or includes a query string or fragment. |
| `invalid_connect_code`           | finish    | The `connect_code` is unknown, expired, already used, or the verifier was wrong.                                              |

All of these return a `400` status.

## Abandoned authorizations

If the user approves access at the provider but the authorization is never
finished, no connected account is saved in Pipes. The provider may still list
your app in the user's connected apps until the user removes it there.

## Keep the verifier in the user's session

Only store the `code_verifier` in the session of the user who started the
authorization. Don't keep it anywhere another request could look it up, such as
a database table keyed by `state`. If any browser that returns with a matching
`state` can retrieve the verifier, another user's browser can finish the
authorization, and the binding no longer protects anything.
