# Store OAuth tokens securely

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

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?

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?

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](/docs/cookbook/use-cases/build/list-revoke-grants/).

## 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](/docs/cookbook/use-cases/build/get-refresh-tokens/) recipe covers the token exchange, and [handle grant expiry](/docs/cookbook/use-cases/build/handle-grant-expiry/) covers recovery without losing sync state.

## 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.

```bash
curl --request POST \
  --url 'https://api.us.nylas.com/v3/connect/token' \
  --header 'Content-Type: application/json' \
  --data '{
    "client_id": "<NYLAS_CLIENT_ID>",
    "client_secret": "<NYLAS_API_KEY>",
    "grant_type": "authorization_code",
    "code": "<CODE>",
    "redirect_uri": "<CALLBACK_URI>"
  }'
```

```python [storeGrant-Python SDK]
response = nylas.auth.exchange_code_for_token(
    request={
        "client_id": os.environ.get("NYLAS_CLIENT_ID"),
        "redirect_uri": "https://myapp.com/callback",
        "code": request.args.get("code"),
    }
)

# Persist only this reference, scoped to your internal user.
save_grant(user_id=current_user.id, grant_id=response.grant_id)
```

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.


> **Info:** 
> **New to Nylas?** Start with the [quickstart guide](/docs/v3/getting-started/) to set up your app and connect a test account before continuing here.


## Keep the API key in a secret manager

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](/docs/dev-guide/best-practices/#encrypt-stored-user-data) guide documents encryption-at-rest and TLS-in-transit requirements, and the [OWASP Key Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Key_Management_Cheat_Sheet.html) covers rotation, separation of duties, and vault selection in depth. For client-side flows where you can't hold a secret, use [PKCE](/docs/v3/auth/hosted-oauth-accesstoken/#secure-the-authentication-process-with-pkce) instead of embedding the key.

## 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](https://cheatsheetseries.owasp.org/cheatsheets/Cryptographic_Storage_Cheat_Sheet.html) 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](/docs/cookbook/use-cases/build/get-refresh-tokens/) recipe documents the BYOA request body.

## Revoke and rotate when access ends

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](/docs/reference/api/authentication-apis/revoke_oauth2_token_and_grant/) 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.

## What's next

- [Get and refresh OAuth tokens](/docs/cookbook/use-cases/build/get-refresh-tokens/) for the token exchange and BYOA request body
- [Handle grant expiry and re-authentication](/docs/cookbook/use-cases/build/handle-grant-expiry/) for the `grant.expired` webhook and recovery
- [Create and revoke API keys](/docs/cookbook/use-cases/build/manage-api-keys/) for rotating the secret that protects every grant
- [Security best practices](/docs/dev-guide/best-practices/#encrypt-stored-user-data) for encryption-at-rest and key management requirements