Skip to content
Skip to main content

Store OAuth tokens securely

Last updated:

A leaked OAuth refresh token is a standing key to someone’s inbox. It mints fresh access tokens on demand, it doesn’t expire on its own, and a provider has no way to know that the request came from an attacker who pulled it out of your database. So the question for any email or calendar integration isn’t only “how do I get a refresh token,” it’s “where does this thing live, and who can read it.”

The grant model answers most of that for you. Instead of holding provider access and refresh tokens, you store one opaque grant_id and let the API keep the real secrets. This recipe covers what to persist, how to protect it, and the one case where you still hold raw tokens yourself.

What is the most secure way to store OAuth credentials for email and calendar?

Section titled “What is the most secure way to store OAuth credentials for email and calendar?”

The most secure approach is to not store the provider’s OAuth tokens at all. With hosted authentication, you persist a single opaque grant_id per connected account, and the API holds and refreshes the provider’s access and refresh tokens server-side. A grant_id is useless without your API key, so a database leak alone exposes nothing.

This flips the usual threat model. A raw refresh token in your database is a live credential: anyone who reads it can call Gmail or Microsoft Graph directly, for as long as the token stays valid. A grant_id is just a 36-character UUID reference, like e19f8e1a-eb1c-41c0-b6a6-d2e59daf7f47. To act on it, a caller also needs your API key, which lives in a separate secrets vault and never touches the rows your application stores. You’ve split a single secret into two, kept in two systems, so one breach isn’t enough.

How do SaaS companies typically handle OAuth token management across thousands of users?

Section titled “How do SaaS companies typically handle OAuth token management across thousands of users?”

Most SaaS teams centralize token storage so individual application servers never see raw provider tokens. They store one reference ID per user, refresh tokens automatically through a single service, and keep the high-value secret in a managed vault. The grant model packages exactly this pattern: one grant_id per account, refresh handled for you, across all 6 providers.

At scale, the operational cost of doing this by hand is what bites. A team supporting Google plus Microsoft directly runs two OAuth projects, two refresh schedules, and two sets of token rows, each a separate place a leak can happen. A GET /v3/grants call lists every connected account behind one schema, returning 10 grants per page by default and paging through the rest with offset, and the same grant_id reaches Google, Microsoft, Yahoo, iCloud, IMAP, and Exchange. You write one storage path and one access-control policy instead of one per provider. To audit which accounts are connected and clean up stale ones, see list and revoke grants.

How do I handle OAuth token refresh for long-lived email integrations?

Section titled “How do I handle OAuth token refresh for long-lived email integrations?”

In API-key mode, refresh is automatic and you write zero refresh code. The API stores the provider’s access and refresh tokens on the grant and renews the access token before it expires, so a sync that runs for months keeps working. Provider access tokens commonly expire after 3,600 seconds (1 hour); you never schedule around that.

This is the practical payoff of storing a grant_id instead of raw tokens. Every API call goes out with the grant_id plus your API key, and the renewal happens behind that boundary. Refresh can still fail when a user changes their password or revokes consent at the provider, which moves the grant to an invalid status. Subscribe to the grant.expired webhook to catch it, since sync health checks can take up to 10 minutes to flag the change. The get and refresh OAuth tokens recipe covers the token exchange, and handle grant expiry covers recovery without losing sync state.

Store the grant ID, not the provider tokens

Section titled “Store the grant ID, not the provider tokens”

After a user approves access, you exchange the one-time code with a single POST /v3/connect/token request. The response returns a Nylas access_token, an id_token, the grant_id, and (when you request the code with access_type=offline) a Nylas refresh_token for minting new access tokens. None of these is the provider’s own access_token or refresh_token. The grant_id is the one value you persist, tied to your internal user row; let the API key plus the grant_id mint short-lived tokens on demand rather than storing the Nylas refresh_token long-term.

The request below finishes the OAuth flow and returns the grant. The code is single-use, so a failed exchange means restarting the flow. Notice what you store afterward: a reference, not a secret.

A grant_id column doesn’t need encryption the way a refresh token does, because it can’t authorize anything on its own. The real secret, your API key, stays in a vault.

Your API key is the credential that turns a grant_id into mailbox access, so treat it like a database root password. Keep it in a managed secret store such as AWS Secrets Manager, Google Secret Manager, or HashiCorp Vault, injected at runtime through an environment variable. Never commit it, never ship it to browser or mobile code, and never log it.

One leaked API key exposes every connected mailbox across all 6 providers at once, which is why its blast radius is the whole application, not one user. Rotate it on a schedule and immediately after any suspected exposure. The Nylas security best practices guide documents encryption-at-rest and TLS-in-transit requirements, and the OWASP Key Management Cheat Sheet covers rotation, separation of duties, and vault selection in depth. For client-side flows where you can’t hold a secret, use PKCE instead of embedding the key.

When you do hold raw refresh tokens: encrypt them

Section titled “When you do hold raw refresh tokens: encrypt them”

Bring Your Own Authentication (BYOA) is the one path where you keep provider refresh tokens yourself. If you already run your own OAuth flow, you pass each existing refresh_token to POST /v3/connect/custom to create a grant directly, and the response returns a grant_id with grant_status set to valid. The catch: until you hand a token over, you own its storage, and that token is a live credential.

Encrypt every refresh token at rest with a key from a managed KMS, using an authenticated cipher like AES-256-GCM, and keep the encryption key in the vault rather than alongside the ciphertext. The OWASP Cryptographic Storage Cheat Sheet lists the algorithm and key-handling rules to follow. BYOA fits migrations well: you move users into grants with 1 request each and no re-consent. The honest tradeoff is that this is the more dangerous storage model, so most teams should let hosted auth hold the tokens and only reach for BYOA when an existing token store forces it. The get and refresh OAuth tokens recipe documents the BYOA request body.

Revoke tokens the moment a user disconnects, re-authenticates, or cancels their account, so you never hold a credential you no longer need. Send a POST /v3/connect/revoke request with the token as a query parameter; revoking a parent access token also revokes its child tokens. Nylas access tokens don’t expire on their own, so cleanup is on you.

Holding stale tokens widens your attack surface for no benefit: each unused token is one more credential a breach can leak. The aim is one active token per grant at any time, revoking the previous one whenever a user re-authenticates. The revoke OAuth token reference lists the response codes, including the 400 returned when a token is already expired. Pair revocation with the grant.expired webhook so a forced re-auth at the provider triggers cleanup on your side too, instead of leaving an orphaned token in the database.