You did everything right. You integrated Google OAuth, or Facebook Login, or Salesforce, or whatever provider your product needs. Users authorized your app, tokens were stored, API calls worked beautifully. You shipped. You celebrated.
Three months later, support tickets start rolling in. "I got logged out." "My calendar sync stopped working." "The integration says it needs to be reconnected." Not all users — just some. Randomly. No deploy, no code change, no obvious trigger.
What actually happened
OAuth access tokens expire. Every provider sets different lifetimes — Google gives you 1 hour, Microsoft gives you 1 hour, Facebook gives you 60 days, Salesforce gives you anywhere from 15 minutes to 24 hours. When the access token expires, your app needs to use the refresh token to get a new one.
But here's what they don't emphasize in the OAuth tutorials: refresh tokens expire too. Google revokes refresh tokens unused for 6 months, or if a user changes their password, or if you exceed 50 refresh tokens per account. Microsoft expires them after 90 days of inactivity. Facebook's long-lived tokens last 60 days but can't be refreshed after that.
And the worst part: most apps only refresh tokens reactively — when a user happens to trigger an API call. If a user goes inactive for a few months, their refresh token silently expires, and the next time they log in, everything is broken.
The five mistakes everyone makes
1. **Storing tokens in plaintext.** Your database has access tokens and refresh tokens sitting in regular string columns. If your database is breached, every user's OAuth credentials are exposed. Tokens should be encrypted at rest with AES-256-GCM or equivalent.
2. **No expiry tracking.** You stored the token but not when it expires. When it stops working, you can't tell whether it expired normally, was revoked by the user, or was invalidated by the provider.
3. **Reactive-only refresh.** You only refresh tokens when a user triggers an API call. Users who go inactive for weeks or months silently lose their connections. By the time they come back, the refresh token is dead too.
4. **No monitoring.** You have no dashboard showing which users have healthy tokens, which are about to expire, and which have already failed. Problems are invisible until users complain.
5. **No rotation cron job.** You don't have a background process that proactively checks token health and refreshes expiring tokens before they die. This is the single most impactful thing you can add.
What proactive rotation looks like
# Run daily via cron or scheduler
from stablestack_token_manager.rotation import TokenRotationJob
job = TokenRotationJob(
provider=GenericOAuthProvider("google", "https://oauth2.googleapis.com/token"),
refresh_threshold_days=30,
warn_threshold_days=7,
)
# Dry run first to see what would be refreshed
results = job.run(dry_run=True)
print(f"{len(results.refreshed)} tokens would be refreshed")
print(f"{len(results.failed)} tokens need re-authentication")
# Then run for real
results = job.run(dry_run=False)A production token management system needs four things:
- **Encrypted storage** — tokens encrypted at rest with unique IVs per credential - **Expiry tracking** — store the expiration timestamp and check it before every API call - **A rotation cron job** — runs daily, finds tokens expiring within 30 days, and refreshes them proactively - **A monitoring dashboard** — shows every credential's status, expiry, and last refresh time
The cron job is the key. Instead of waiting for a user to trigger a refresh, it runs on a schedule and keeps all tokens alive. When it finds a token that can't be refreshed (revoked by user, provider error), it marks it for re-authentication and can notify the user proactively.
The provider gotchas nobody warns you about
class GenericOAuthProvider(OAuthProvider):
"""Standard OAuth2 token refresh endpoint."""
def __init__(self, name: str, token_url: str):
self.name = name
self.token_url = token_url
def refresh_token(self, refresh_token, client_id, client_secret):
response = requests.post(self.token_url, data={
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": client_id,
"client_secret": client_secret,
})
return RefreshResult.from_response(response)Each OAuth provider has quirks that will bite you:
**Google:** Refresh tokens are revoked if a user changes their password. If your app is in "testing" mode in the Google Cloud Console, refresh tokens expire after 7 days. Google limits each user to 50 outstanding refresh tokens per OAuth client — the 51st revokes the oldest.
**Microsoft:** Refresh tokens are invalidated after 90 days of inactivity. If you request `offline_access` scope but don't use the refresh token within 90 days, it dies.
**Facebook:** Long-lived tokens last 60 days and cannot be refreshed after expiration. You must get a new one through the full OAuth flow. If a user changes their Facebook password, all tokens are invalidated.
**Salesforce:** Refresh tokens can be configured to never expire, but the admin can set policies that force expiration. Connected app policies can revoke tokens at any time.
A generic provider interface lets you handle all of these with the same codebase:
One command to add all of this
$ stablestack add-token-manager
Created stablestack_token_manager/encryption.py
Created stablestack_token_manager/models.py
Created stablestack_token_manager/rotation.py
Created stablestack_token_manager/api.py
Created scripts/rotate_tokens.py
Created stablestack_token_manager/ui/TokenDashboard.tsx
Created .claude/commands/token-manager.mdWe built this because we hit every one of these problems ourselves. StableStack's token manager scaffold generates the complete system — encrypted storage, CRUD API, rotation cron job, and React dashboard — in one command:
What you get
**Encryption module** — Fernet (AES-128-CBC+HMAC) and AES-256-GCM implementations. Transparent encrypt/decrypt through model properties so you never handle plaintext tokens in application code.
**Credential model** — SQLAlchemy model (Python) or Prisma + CredentialService (TypeScript) with hybrid properties for encryption, status tracking, and safe serialization that masks tokens.
**Rotation engine** — Generic OAuth provider interface, configurable refresh thresholds (default: 30 days), a TokenRotationJob that supports dry runs, and a standalone cron script you can deploy anywhere.
**CRUD API** — FastAPI router (Python) or Express router (TypeScript) with endpoints for listing credentials (tokens masked), creating, updating, validating against the provider, force-refreshing, and bulk rotation.
**React dashboard** — A drop-in TokenDashboard component that shows every credential with status badges (valid, expiring soon, expired, needs re-auth), test buttons, refresh buttons, and a "Rotate All" action.
**Claude Code slash command** — Use /token-manager inside Claude Code and it finds hardcoded tokens in your codebase, migrates them to encrypted storage, and wires up the rotation job.
Available in both Python and TypeScript. Auto-detects your project language, or specify with --language.
What to do right now
If you have OAuth tokens in production, check these immediately:
1. Are tokens encrypted at rest? If you can read them in your database, so can an attacker. 2. Do you store the expiration timestamp? If not, you're flying blind. 3. Do you have any background process that proactively refreshes tokens? If not, inactive users are silently losing their connections right now. 4. Can you see which tokens are healthy and which are about to expire? If not, you won't know there's a problem until users tell you.
The first three months after an OAuth launch are the honeymoon period — everything works because tokens are fresh. The problems start at month three, six, twelve — whenever your shortest-lived tokens hit their expiry. Don't wait for the support tickets. Set up proactive rotation now.
Free with every install. No license key required.
pip install stablestack