# Handle the OAuth callback on your backend

Source: https://developer.nylas.com/docs/v3/auth/nylas-connect/backend-oauth/

You want the popup flow, but you don't want Nylas tokens in the browser and you don't have an identity provider to link grants against. `@nylas/connect` supports this: the browser opens the popup, your backend handles the callback, and tokens never touch client storage.

If you do have an identity provider, use it instead. The [external IdP guides](/docs/v3/auth/nylas-connect/use-external-idp/) cover Auth0, Clerk, Google, WorkOS, and custom JSON Web Key Set (JWKS) endpoints, and that path also lets you address mailboxes by your own user IDs.

## When should I handle the callback on my backend?

Handle the callback on your backend when you have your own authentication system and want the browser to stay out of the token path. Your backend exchanges the code using your API key rather than PKCE, stores the tokens in your database, and links each grant to a user through the OAuth `state` parameter.

Choose this over the default browser flow when any of these 4 conditions apply:

- You want tokens stored server-side rather than in `localStorage`.
- You need to link several mailboxes to a single user account.
- You make Nylas API calls from your backend using an API key.
- Your existing auth system issues sessions rather than JWTs.

## How the flow works

The `state` parameter carries the link between an authenticated user and the OAuth flow. Your backend generates it, the browser passes it to the popup, and your backend reads it back on the callback to know which user just connected a mailbox. The flow has 5 steps:

1. Your frontend requests a `state` value from your backend, which ties it to the signed-in user.
2. Your frontend opens the `@nylas/connect` popup with that `state`.
3. Your backend receives the callback, and exchanges the code for tokens using `client_secret`.
4. Your backend links the resulting grant to the user through `state`.
5. Your backend makes Nylas API calls with your API key.

## Configure the client

Moving the callback off the browser takes 3 settings. Point `redirectUri` at your backend, set `autoHandleCallback` to `false` so the browser doesn't race your server for the one-time code, and set `persistTokens` to `false` to keep tokens out of `localStorage`.

```tsx


const { connect } = useNylasConnect({
  clientId: "your-client-id",
  redirectUri: "https://yourbackend.com/api/oauth/callback",
  autoHandleCallback: false,
  persistTokens: false,
});

async function handleConnect() {
  const { state } = await fetch("/api/oauth/init-state", {
    credentials: "include",
  }).then((r) => r.json());

  await connect({
    method: "popup",
    state,
    provider: "google",
  });
}
```

| Setting              | Value                | Why                                                   |
| -------------------- | -------------------- | ----------------------------------------------------- |
| `redirectUri`        | Your backend URL     | Your backend processes the callback, not the frontend |
| `autoHandleCallback` | `false`              | Stops the browser from exchanging the code            |
| `persistTokens`      | `false`              | Keeps tokens out of browser storage                   |
| `state`              | Generated by backend | Links the OAuth flow to an authenticated user         |

## Generate the state parameter

Generate `state` server-side from a secure random source and give it a short lifetime. This example uses 32 random bytes and expires the mapping after 600 seconds, which is long enough for a user to finish an OAuth prompt but short enough that a leaked value is useless.

```typescript
// POST /api/oauth/init-state


app.post("/api/oauth/init-state", authenticatedMiddleware, async (req, res) => {
  const userId = req.user.id;
  const state = crypto.randomBytes(32).toString("hex");

  await redis.setex(`oauth:state:${state}`, 600, userId);

  res.json({ state });
});
```

Never accept a `state` value the client supplies. The whole point is that your backend is the only party that knows which user a given `state` belongs to.

## Exchange the code for tokens

Your callback route reads the 2 query parameters `state` and `code` from the URL, resolves `state` back to a user, and posts to the token endpoint with your API key as `client_secret`. Each OAuth `code` is single-use, so a failed exchange means restarting the flow rather than retrying with the same code.

```typescript
// GET /api/oauth/callback
app.get("/api/oauth/callback", async (req, res) => {
  try {
    const callbackUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
    const urlParams = new URLSearchParams(new URL(callbackUrl).search);
    const state = urlParams.get("state");
    const code = urlParams.get("code");

    if (!state || !code) throw new Error("Missing OAuth parameters");

    const userId = await redis.get(`oauth:state:${state}`);
    if (!userId) throw new Error("Invalid or expired state");

    const tokenResponse = await fetch("https://api.us.nylas.com/v3/connect/token", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        client_id: process.env.NYLAS_CLIENT_ID,
        client_secret: process.env.NYLAS_API_KEY,
        redirect_uri: process.env.NYLAS_CALLBACK_URL,
        code,
        grant_type: "authorization_code",
      }),
    });

    const tokenData = await tokenResponse.json();

    await db.grants.create({
      userId,
      grantId: tokenData.grant_id,
      email: tokenData.email,
      provider: tokenData.provider,
    });

    await redis.del(`oauth:state:${state}`);

    res.send(`
      <html><body>
        <script>
          window.opener?.postMessage({ type: 'NYLAS_CONNECT_SUCCESS' }, '*');
          window.close();
        </script>
      </body></html>
    `);
  } catch (error) {
    res.status(400).send("Authentication failed");
  }
});
```

Pass `client_secret` as your Nylas API key. See the [token exchange reference](/docs/reference/api/authentication-apis/exchange_oauth2_token/) for the full request schema.

## Store grants against your users

On this path you store the grant ID, because there's no IdP claim for Nylas to link the grant against. Keep 1 row per connected mailbox keyed on your user ID, which also gives you multiple mailboxes per user for free.

```typescript
await db.grants.create({
  userId,
  grantId: tokenData.grant_id,
  email: tokenData.email,
  provider: tokenData.provider,
});
```

To skip this table entirely and keep querying by your own user ID, connect through an identity provider instead. See [using external identity providers](/docs/v3/auth/nylas-connect/use-external-idp/).

## Call the API from your backend

Once grants are stored, call the API with your API key and the stored `grantId`. A user with 3 connected mailboxes means 3 requests, 1 per grant, which you can issue concurrently.

```typescript
app.get("/api/emails", authenticatedMiddleware, async (req, res) => {
  const grants = await db.grants.findMany({ where: { userId: req.user.id } });

  const emails = await Promise.all(
    grants.map(async (grant) => {
      const response = await fetch(
        `https://api.us.nylas.com/v3/grants/${grant.grantId}/messages`,
        { headers: { Authorization: `Bearer ${process.env.NYLAS_API_KEY}` } },
      );
      return response.json();
    }),
  );

  res.json(emails);
});
```

## What does autoHandleCallback do?

`autoHandleCallback` controls whether the browser processes OAuth callback parameters on its own. It defaults to `true`, and when enabled the client detects the 2 callback parameters in the URL, exchanges the code using PKCE, and strips them from the address bar.

Set it to `false` whenever your backend owns the exchange. Leaving it at `true` means the browser and your server both try to spend the same one-time `code`, and whichever loses gets an error.

## callback() or handleRedirectCallback()?

Of the 2 callback methods, use `callback(url)` on a backend and `handleRedirectCallback()` only in a browser. The difference is that `callback()` accepts an explicit URL, which a server needs because there's no `window.location` to read, and it supports both the popup and inline methods.

| Method                     | Popup | Inline | Where it runs  |
| -------------------------- | ----- | ------ | -------------- |
| `callback(url?)`           | Yes   | Yes    | Backend routes |
| `handleRedirectCallback()` | No    | Yes    | Browser only   |

On a backend, always pass the full callback URL:

```typescript
const callbackUrl = `${req.protocol}://${req.get("host")}${req.originalUrl}`;
const result = await nylasConnect.callback(callbackUrl);
```

The `url` argument is optional in a browser, where the client reads `window.location`, and required on a server.

## What's next

- [Using external identity providers](/docs/v3/auth/nylas-connect/use-external-idp/) removes the grants table by linking mailboxes to your IdP identity.
- [`NylasConnect.callback()`](/docs/v3/auth/nylas-connect/nylasconnect-class/callback-methods/nylasconnect-callback/) documents the method signature and return type.
- [Token exchange reference](/docs/reference/api/authentication-apis/exchange_oauth2_token/) lists every field the endpoint accepts.
- [Handling expired grants](/docs/dev-guide/best-practices/grant-lifecycle/) covers detection and recovery once mailboxes are connected.