# OAuth for a desktop email app

Source: https://developer.nylas.com/docs/cookbook/use-cases/build/desktop-oauth/

A desktop email client can't keep a client secret safe. Anyone who installs your app can unpack the binary and read whatever you shipped inside it, so the web pattern of authenticating with a server-side secret falls apart on the desktop. The fix is Proof Key for Code Exchange (PKCE): your app generates a one-time secret per login instead of embedding a permanent one.

This recipe covers the desktop-specific pieces only: PKCE, a loopback redirect that catches the callback on the user's own machine, launching the system browser, and storing tokens in the OS keychain. For the shared authorization basics, scopes, and the grant model, read [Connect user accounts with OAuth](/docs/cookbook/use-cases/build/connect-user-accounts-oauth/) first.

## Why does a desktop app need PKCE instead of a client secret?

A desktop binary is a public client: it runs on a machine you don't control, so any secret compiled into it is readable by the user. PKCE replaces the static client secret with a fresh secret generated at the start of each login, which means a leaked binary exposes nothing reusable. Nylas supports PKCE on its hosted OAuth flow across all 6 providers.

The flow works in two halves. Before sending the user to authorize, your app creates a random `code_verifier` and hashes it with SHA-256 to produce a `code_challenge`. You send only the challenge in the authorization request, so it can travel through the browser safely. When you later exchange the returned `code` for tokens, you send the original `code_verifier`. Nylas re-hashes it and confirms it matches the challenge from step one. An attacker who intercepts the authorization code never sees the verifier, so the stolen code is worthless. This is the same model the [hosted OAuth access token guide](/docs/v3/auth/hosted-oauth-accesstoken/) documents for SPAs and mobile apps.

## How do I generate the PKCE code verifier and challenge?

Create the `code_verifier` as a random URL-safe string, then derive the `code_challenge` by hashing it with SHA-256 and base64url-encoding the result. The verifier should be between 43 and 128 characters of high-entropy randomness. Store it in memory only for the duration of one login, and never write it to disk.

Use your platform's secure random generator, not a general-purpose pseudo-random function. The snippet below produces a verifier from 32 bytes of randomness and the matching challenge. The 32-byte source yields a 43-character verifier after encoding, which sits at the low end of the allowed range.

```js [pkcePair-Node.js]


const base64url = (buffer) =>
  buffer
    .toString("base64")
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");

const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(
  crypto.createHash("sha256").update(codeVerifier).digest(),
);
const state = base64url(crypto.randomBytes(16)); // CSRF guard you verify on return
```

Generate the `state` value from the same secure random source, never from `Math.random()`. Hold the `codeVerifier` and `state` values until the token exchange. You'll pass the verifier then, and the `codeChallenge` and `state` go into the authorization URL next.

## How do I build the authorization URL with a loopback redirect?

Point the `redirect_uri` at a loopback address on the user's own machine, such as `http://127.0.0.1` on a port your app opens. The desktop app runs a tiny local HTTP server, and the provider redirects the browser back to that port with the authorization code. This keeps the entire callback on the local machine, so no public callback server is involved.

Pick a free port at runtime rather than hardcoding one, since a fixed port can collide with other software. After binding, build the authorization URL with your client ID, the loopback redirect, the SHA-256 `code_challenge`, a random `state` value you verify on return, and `access_type=offline` so the token exchange returns a `refresh_token` to store. Add this exact redirect URI to your Nylas application's callback URIs in the Dashboard and set the callback URI's platform to `desktop` (via the Dashboard or the [add callback URI API](/docs/reference/api/)), which makes the `client_secret` optional for the PKCE token exchange. Otherwise the provider rejects the request during the roughly 30 seconds the user spends approving access.

```bash
GET https://api.us.nylas.com/v3/connect/auth?
  client_id=<NYLAS_CLIENT_ID>
  &redirect_uri=http://127.0.0.1:8392/callback
  &response_type=code
  &provider=google
  &code_challenge_method=S256
  &code_challenge=<CODE_CHALLENGE>
  &state=<RANDOM_STATE>
  &access_type=offline
```

## How do I launch the system browser instead of an embedded webview?

Open the authorization URL in the user's default browser, not an embedded webview inside your app. Providers including Google block sign-in from embedded webviews because they can't verify the page or reuse the user's existing session, and an in-app webview also lets your code observe everything the user types. The system browser keeps credentials out of your process entirely.

Most platforms expose a command to open a URL: `open` on macOS, `xdg-open` on Linux, and `start` on Windows. After you open the URL, your local loopback server waits for the redirect carrying the `code`. A reasonable timeout for that wait is 300 seconds, after which you cancel the login and let the user retry. Use `execFile` and pass the URL as an argument rather than interpolating it into a shell string, so a crafted URL can't inject shell commands.

```js [openBrowser-Node.js]


// Pass authUrl as an argument, never interpolated into a shell command string.
if (process.platform === "darwin") {
  execFile("open", [authUrl]);
} else if (process.platform === "win32") {
  // The empty "" is the window title; cmd treats the first quoted arg as the title.
  execFile("cmd", ["/c", "start", "", authUrl]);
} else {
  execFile("xdg-open", [authUrl]);
}
```

## How do I exchange the code for a grant with the verifier?

When the loopback server receives the redirect, pull the `code` and `state` from the query string, confirm `state` matches what you sent, then POST to `/v3/connect/token` with the `code` and your stored `code_verifier`. Because the callback URI's platform is set to `desktop`, the `client_secret` is optional and the `code_verifier` proves the request in its place. A successful response returns an `access_token`, a `refresh_token` (because you requested `access_type=offline`), and the `grant_id` that identifies the connected account.

The access token returned here expires after 3,600 seconds, so plan to refresh it. The response also includes the provider name and the connected email address. The request below shows the desktop-specific fields; the [token exchange reference](/docs/reference/api/) documents every field in full.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/connect/token' \
  --header 'Content-Type: application/json' \
  --data '{
    "client_id": "<NYLAS_CLIENT_ID>",
    "redirect_uri": "http://127.0.0.1:8392/callback",
    "grant_type": "authorization_code",
    "code": "<CODE_FROM_CALLBACK>",
    "code_verifier": "<CODE_VERIFIER>"
  }'
```

## What's the most secure way to store tokens on the desktop?

Store the `refresh_token` in the operating system's native credential store, never in a plaintext file or a config directory. macOS exposes the Keychain, Windows has the Credential Manager, and most Linux desktops provide the Secret Service API through libsecret. These stores encrypt secrets at rest and gate access to them by the logged-in OS user, which a flat JSON file in the home directory does nothing to protect.

Keep the short-lived `access_token` in memory and treat the `refresh_token` as the only value worth persisting. With it you can mint a new access token whenever the current one passes its lifetime of 3,600 seconds. Save the non-secret `grant_id` alongside your app's normal settings so you know which account a stored refresh token belongs to. For the refresh request itself, see [Get and refresh OAuth tokens](/docs/cookbook/use-cases/build/get-refresh-tokens/), and review the [authentication overview](/docs/v3/auth/) for how grants behave over their lifetime.

## What's next

- [Connect user accounts with OAuth](/docs/cookbook/use-cases/build/connect-user-accounts-oauth/) for the shared scopes, grant model, and revocation flow
- [Get and refresh OAuth tokens](/docs/cookbook/use-cases/build/get-refresh-tokens/) to keep a desktop grant alive after the access token expires
- [Hosted OAuth with access token and PKCE](/docs/v3/auth/hosted-oauth-accesstoken/) for the full PKCE exchange and SDK examples
- [Authentication overview](/docs/v3/auth/) for every auth method and the grant lifecycle
- [API reference](/docs/reference/api/) for the complete token exchange parameters