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.
How the flow works
Section titled “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:
- Your frontend requests a
statevalue from your backend, which ties it to the signed-in user. - Your frontend opens the
@nylas/connectpopup with thatstate. - Your backend receives the callback, and exchanges the code for tokens using
client_secret. - Your backend links the resulting grant to the user through
state. - Your backend makes Nylas API calls with your API key.
Configure the client
Section titled “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.
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", });}| 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
Section titled “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.
// POST /api/oauth/init-stateimport 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.
Exchange the code for tokens
Section titled “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.
// GET /api/oauth/callbackapp.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.
Store grants against your users
Section titled “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.
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.
Call the API from your backend
Section titled “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.
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?
Section titled “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()?
Section titled “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:
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
Section titled “What’s next”- Using external identity providers removes the grants table by linking mailboxes to your IdP identity.
NylasConnect.callback()documents the method signature and return type.- Token exchange reference lists every field the endpoint accepts.
- Handling expired grants covers detection and recovery once mailboxes are connected.