Store calendar OAuth tokens safely: encryption, refresh and revocation
Updated
TL;DR
Encrypt refresh tokens at rest with authenticated encryption and keep the key away from the data, never log them, and refresh once per account even when many requests arrive together. Microsoft and Zoom rotate refresh tokens on every use, so save the new one each time, or the next refresh fails. Expect tokens to die: Google's after six months unused or 7 days for apps in Testing, Microsoft's and Zoom's after 90 days, and any of them when the user revokes access.
What you're protecting
A refresh token is standing access to someone's calendar: whoever holds it, with your client secret, can read and change that person's events until it expires or is revoked. Access tokens last about an hour; refresh tokens last weeks to months. Treat refresh tokens like passwords that someone else gave you to keep.
Encrypt them, and keep the key elsewhere
OWASP's Secrets Management cheat sheet says secrets "must be stored encrypted", that "You should not store keys next to the secrets they encrypt", and that secrets should "Never be logged." Its Cryptographic Storage cheat sheet adds: "Do not hard-code keys", and "authenticated modes should always be used."
In practice:
- Encrypt each token field with an authenticated cipher before it reaches the database, so a copy of the database alone is useless and tampering is detected.
- Keep keys out of the database and the code, in a key manager or your platform's secrets store. Envelope encryption (a master key that wraps data keys) makes rotation cheap.
- Label every sealed value with its key ID, so you can rotate keys and re-encrypt without downtime.
- Keep tokens out of logs, errors and analytics. Redact
Authorizationheaders,codeandtokenparameters, and token-like body fields before anything is written.
Refresh without races
Two requests for the same account arrive together, both see an expired access token, and both refresh. With Microsoft and Zoom, that breaks things: "Refresh tokens replace themselves with a fresh token upon every use", and Zoom says "You should always use the latest refresh token for the next refresh request." If the slower request saves the token the faster one already used, the next refresh fails.
Refresh once per account at a time: a lock per account (in-process plus a shared lock across servers), and everyone else waits for that refresh's result. Save the new refresh token whenever the provider returns one. Google returns a new one only sometimes; Microsoft returns one every time. Microsoft also notes that it "doesn't revoke old refresh tokens", so delete the old one yourself once you have the new one.
Why tokens stop working
Google (OAuth 2.0 overview):
- the user revoked access,
- the token "has not been used for six months",
- the app's publishing status is Testing, where refresh tokens expire in 7 days (details),
- the account went past 100 live refresh tokens for your client, and "creating a new refresh token automatically invalidates the oldest refresh token without warning",
- a Workspace administrator restricted the service (
admin_policy_enforced), - the user changed their password, but only for tokens that include Gmail scopes.
Microsoft (refresh tokens): a default lifetime of 90 days, extended with each use. For a web app, a password the user changes themselves leaves tokens alive, but an administrator resetting the password, or revoking the user's sessions, ends them.
Zoom (OAuth): "Refresh tokens expire after 90 days", and access tokens after one hour.
All three answer a dead refresh token with invalid_grant. Mark the connection as broken, stop retrying, and ask the user to reconnect.
Revocation
- Google:
POST https://oauth2.googleapis.com/revokewith the token. Revoking an access token revokes its refresh token too, though "it might take some time before the revocation has full effect." - Zoom:
POST https://zoom.us/oauth/revokewith your client credentials and the token. - Microsoft: there is no call for an app to revoke its own grant. Users remove apps from their account's app permissions, and administrators can revoke a user's sessions.
Users can always revoke your access from their Google, Microsoft or Zoom account settings, and your next refresh will fail with invalid_grant.
How Pyas does it
When users connect through Pyas, you never hold their provider tokens:
- Encrypted at rest. Access and refresh tokens are encrypted before they are stored, with authenticated encryption, keys kept apart from the database, and a separate key for each kind of secret, so a provider token can never be opened as anything else.
- One refresh per account. However many requests arrive together, a token is refreshed once, with a lock across servers, and a rotated refresh token is saved every time.
- Dead tokens reported. An
invalid_grantmarks the account as needing reconnection, your app gets aconnection.brokenwebhook, and the user reconnects inside your app with PyasConnect, keeping the sameaccountId. A successful reconnect sendsconnection.reauthorized. - Nothing in the logs. Credentials are redacted from every log, and request bodies aren't logged at all.
Read more on the security page.