Skip to content
Skip to main content

Handle the OAuth callback on your backend

Last updated:

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 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?

Section titled “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.

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.

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.

import { useNylasConnect } from "@nylas/react/connect";
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",
});
}
SettingValueWhy
redirectUriYour backend URLYour backend processes the callback, not the frontend
autoHandleCallbackfalseStops the browser from exchanging the code
persistTokensfalseKeeps tokens out of browser storage
stateGenerated by backendLinks the OAuth flow to an authenticated user

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.

// POST /api/oauth/init-state
import crypto from "crypto";
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.

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.

// 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 for the full request schema.

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.

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.

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.

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);
});

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.

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.

MethodPopupInlineWhere it runs
callback(url?)YesYesBackend routes
handleRedirectCallback()NoYesBrowser only

On a backend, always pass the full callback URL:

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.