Skip to main content
When a user signs in with Google or GitHub, theAuth stores the provider’s access token and refresh token against the linked account. The social token API is how your server code uses them later: call the provider’s API on the user’s behalf, show which accounts are linked, and let the user disconnect one. It lives on the OAuth module, which the oauth() plugin exposes through the plugin context.
If you build the module yourself, createOAuthModule(db, config) returns the same three methods.

getAccessToken

The result is { accessToken, tokenType, expiresAt, refreshed }. expiresAt is null when the provider never said, and such a token is returned as is. refreshed is true when this call had to refresh. If the stored token expires within expirySkewSeconds (default 60), the module exchanges the refresh token for a new one and saves the result before returning. Providers that do not rotate refresh tokens keep the old one. Concurrent calls for the same account in one process share a single refresh, so a provider that rotates refresh tokens sees one use, not several. If a refresh loses a race with another instance and fails, the module re-reads the row and returns the winner’s token when it is fresh.

Errors

Failures throw an error with a typed code: OAuthTokenError and OAuthRefreshError are exported from @glinr/theauth/auth. The error carries a code from the table above and the provider id.

Which providers can refresh

A provider can refresh when its definition has refreshTokens. All of these do:
  • Atlassian, Discord, Dropbox, Figma, GitHub, GitLab, Google, LinkedIn, Microsoft, Reddit, Spotify, Twitch, Twitter and Zoom
  • genericOIDC and the presets built on it, such as facebookProvider, tiktokProvider, auth0Provider and oktaProvider
These do not: createAppleProvider, createSlackProvider and createNotionProvider. For them getAccessToken returns the stored token while it is fresh or has no expiry, and fails once an expiry has passed: no_refresh_token when none is stored, otherwise refresh_failed. The notionProvider preset is built on genericOIDC and does implement refresh. Having refreshTokens is not the same as holding a refresh token. Providers only issue one in some cases, for example Google returns it on the first consent, and a later sign-in may not include a new one. When none was ever stored, an expired token fails with no_refresh_token. A custom provider opts in by implementing refreshTokens(refreshToken) and throwing OAuthRefreshError with reauth: true on invalid_grant.

listAccounts

No token material is ever included, only whether a refresh token exists.

unlinkAccount

This deletes the stored row. It throws no_account when nothing is linked.

The last-credential guard

Unlinking is refused with last_credential when the account is the user’s only way to sign in. These count as another way automatically:
  • another linked OAuth account
  • a username and password account
  • a passkey
For anything else (magic link, email OTP, phone), tell the module with hasOtherSignInMethod:
Without that callback the guard cannot see those methods and may block an unlink that would have been safe. It errs toward keeping the user signed in. Unlinking does not call the provider to revoke the grant. If you need that, revoke it at the provider before you unlink.

HTTP routes

The oauth() plugin also adds two signed-in routes, relative to your adapter prefix: Both return 401 without a session. The access token itself is never exposed over HTTP. Read it server side with getAccessToken.

Encrypt tokens at rest

By default provider tokens are stored as plain text. Add tokenEncryption to store them sealed with AES-256-GCM, the same envelope the token vault uses:
Generate a key with node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))".
  • Each value is bound to its account id and field, so a ciphertext copied to another row will not decrypt.
  • Sealed values carry an enc: prefix. Existing plaintext rows keep working and are re-encrypted the next time they are written (sign-in, linking or a refresh).
  • Startup throws if encryption is enabled without keys or with an activeKeyId that is not in keys.
  • To rotate, add a new key, point activeKeyId at it and keep the old key listed until every row has been rewritten. A row sealed with a key you removed fails with decrypt_failed.
  • Set enabled: false to stop encrypting without removing the keys. Existing sealed rows still need the keys to be read.

OAuth overview

Providers, the sign-in flow and account linking.

Token vault

Give agents delegated access to provider tokens with consent and audit.

Sign-in gate

The OAuth callback now goes through the gate before a session is issued.
Last modified on October 13, 2026