How I Self-Healed Schwab’s 7-Day OAuth Expiry
Schwab’s market-data API hands you a token that hard-expires every 7 days — by design, not by bug. Instead of setting a reminder to renew it by hand, I built a pipeline that renews itself before it dies and only emails me when it genuinely can’t.
Ingredients
- Schwab Market Data API — real-time index and options data, tied to an API-only brokerage account (free with the account)
- OAuth tokens — temporary passwords the API issues that expire and must be refreshed (the whole reason this post exists)
- Headless Linux server — the always-on Alienware from the earlier posts, running the daily check (already set up)
- A cron job — a scheduled task Linux runs automatically once a day (free)
- Resend — email API, used only to alert me when the self-heal fails (free tier)
- Claude Code — terminal AI for writing the refresh logic and the fail-safe alerting ($200/yr)
Worth saying up front: the brokerage account behind this API holds under a dollar. It exists purely to unlock the free market-data feed — no meaningful balance, no trading through it. That framing mattered when I decided how much automation to trust with the login: there’s effectively nothing to steal, and the design still errs on the side of caution anyway.
The Problem: A Token That Dies Every Week
Several of my little projects lean on the Schwab Market Data API — the morning market email, an options monitor, a trading-thesis watchdog. They all read from the same live feed. To talk to that feed, you authenticate once and get back an OAuth token — a temporary password the API accepts instead of making you log in every single call.
There are actually two tokens in play, and the distinction is the whole story:
- A short-lived access token — the one every API call actually uses. It expires in about half an hour. That’s fine: the code quietly swaps it for a fresh one whenever it’s about to go stale, and I never notice.
- A longer-lived refresh token — the credential used to mint new access tokens. This is the one Schwab expires every 7 days, hard.
Here’s the sharp edge. On most OAuth systems, a refresh token lets you renew indefinitely — you use it to get a new access token, and often a fresh refresh token comes back with it, resetting the clock. Schwab doesn’t work that way. After 7 days the refresh token is simply dead, and no amount of refreshing revives it. The only way to get a new one is a real login — an actual human-style trip through Schwab’s sign-in and consent screens.
I want to be precise, because it’s easy to read this as “Schwab’s API is broken.” It isn’t. A 7-day hard expiry on the refresh token is a deliberate platform security choice — it caps how long a leaked token stays useful. My job wasn’t to fight the policy. It was to live inside it without a weekly chore.
The naive failure mode: I set everything up, it works beautifully for a week, and then on day 8 every downstream project silently starts getting rejected. No crash, no obvious signal — just stale-or-missing market data leaking into the morning email until I happened to notice something looked off. A pipeline that dies quietly is worse than one that dies loudly.
Version 1: A Reminder to Do It Myself
The first version was honest about its limits. A daily check looked at how old the refresh token was, and once it crossed a threshold, it emailed me: “Schwab token is aging, go re-authenticate.” Then I’d sit down, walk through the login by hand, and the clock reset.
It worked. It also quietly annoyed me every few days. A reminder that only ever tells you to go do a manual chore isn’t automation — it’s a nag with a cron schedule. Worse, it depended on me being reachable. Travel, a busy morning, a missed email, and the token would lapse before I got to it. The reliability of the whole data pipeline was pinned to my inbox habits, which is not a load-bearing thing to depend on.
So the goal changed. Not “remind me to renew.” Renew it for me, and only bother me if you actually can’t.
The Core Idea: Renew Early, Alert on Failure
The whole design collapses to two decisions made once a day by a scheduled check:
- Is the token getting old? Not expired — old. The check triggers a renewal at around day 5 of 7, deliberately early. That two-day buffer is the safety margin: if the automated renewal hits a snag on day 5, there’s still time to try again the next day, or for me to step in, before anything actually breaks on day 7.
- Did the renewal work? If yes, the check goes back to sleep and says nothing. Silence is success. If the renewal genuinely fails, that’s the only time an email goes out — and it comes with enough context to fix it fast.
The most valuable property of this system is that a healthy week is completely silent. I used to get a renewal nag every few days. Now the token quietly rotates in the background and the only email I ever get about Schwab OAuth is one that actually requires my hands. An alert I can trust to mean “act now” is worth ten alerts I learn to ignore.
🔧 Developer section: the daily decision
- A cron job runs the freshness check once every morning
- It reads a small marker recording when the token was last successfully renewed
- If the token is younger than the renewal threshold, it exits immediately — nothing to do
- If it’s at or past the threshold, it kicks off the automated renewal
- It then re-checks the marker: a fresh marker means success and it exits quietly
- Only a failed renewal reaches the email step
The Hard Part: Renewing Without a Human — and the Spectrum of Ways to Do It
Here’s the tension: getting a new refresh token requires a real login, the kind a person does in a browser — but the whole goal is to not need a person. Squaring that means the server drives the login itself, in a headless (no-screen) browser, then catches the fresh token at the end. I’m staying deliberately high-level on the mechanics — this touches a real brokerage login, and a step-by-step would be the wrong thing to publish.
It helps to split the problem in two. Refreshing the short-lived access token (trading a refresh token for a new one every half hour) is standard, endorsed OAuth, spelled out in Schwab’s OAuth guide. The friction is entirely the 7-day hard expiry on the refresh token: once it dies, the only way back is a full login-and-consent flow. That day-7 re-login is what people solve differently, along a security spectrum:
- Manual paste — most secure, least convenient. A tool like schwab-py prints a URL, you sign in by hand, and it catches the result. A human does the login every week.
- Semi-automated helper. A small local web app walks you through the OAuth flow and captures the token — e.g. Schwab-API-OAuth-Manager or this community gist.
- Fully automated headless login — most convenient, most exposure. A headless browser plus a local redirect server does the sign-in unattended, with public examples like schwab-api-auth-automation. This is the end I built toward — and the more of the login you automate, the more places your password and tokens can leak if you’re careless.
Wherever you land, the API client secret, the refresh token, and — above all — your brokerage password stay out of hardcoded literals, out of committed git history, and out of anywhere prone to leaking. Load them at runtime from a locked-down file; never paste them inline where they can end up in a repo, a log, or a screenshot.
The honest gray area: automating the token refresh is fine and endorsed. Automating the login to mint a fresh refresh token every 7 days scripts a flow meant for a human. It’s widely done, but it leans on that sign-in staying scriptable — if Schwab ever added bot-detection or step-up MFA to the authorize screen, the automated approaches would break overnight while the manual-paste one kept working. A pragmatic solution living at the mercy of Schwab’s login hardening, and that’s worth naming plainly.
🔧 Developer section: safety rails on the auto-login
The lesson I’d underline: never hammer a login. On an earlier project I let automation retry a login it was confused about, and the retries got the account soft-locked. So this renewal makes exactly one clean attempt and stops the moment anything looks off:
- Exactly one login submit — it never loops or re-tries the credential
- Bounded, not open-ended — the consent walk is capped at a small fixed number of steps so it can never spin
- Unexpected page → stop — anything that isn’t the exact expected flow ends the run immediately
- A screenshot on failure — so I can see why at a glance
- Time-boxed — if it hangs, it’s killed rather than left to sit
I’m staying at the philosophy level on purpose — I won’t walk through exactly how mine drives the login. But if you’re building something similar and want to compare notes, reach out.
The Backstop: Degrade, Don’t Crash
Self-healing is the happy path. But I wanted the downstream projects to survive even awindow where the token is dead — say the renewal fails on day 5 and I don’t fix it until day 7. So every consumer of the Schwab feed has a fallback: if the live token isn’t working, it quietly falls back to free public data sources for prices instead of throwing an error and killing the whole job.
This is the same principle as the market email from an earlier post: when a data source is down, the email still sends — with a note in the affected section instead of a crash. A missing options chain shouldn’t take down the entire morning briefing. The token renewal makes the good data show up; the fallbacks make sure a bad day is a degraded email, not a dead one.
Designing the One Email That Matters
Since a failure email is now a rare, real event, I put care into making it actionable. When the self-heal can’t recover, the email tells me how old the token is, roughly why the automated attempt failed (a login challenge, a changed page, the browser being down), and the exact command to run to fix it by hand. It’s not “something went wrong” — it’s “here’s the state, here’s the button.”
One more subtlety: I didn’t want a stuck token to email me every single day. So the alert de-duplicates — once it’s warned me, it stays quiet for 24 hours before it’s allowed to warn again. A real problem gets one clear ping, not a daily pile-up that I’d start tuning out.
🔧 Developer section: what the failure email carries
- The token’s current age, and the reminder that 7 days is the hard wall
- A best-guess reason the automated recovery didn’t work
- A pointer to the failure screenshot
- The one command to re-authenticate manually
- A 24-hour cooldown so a persistent failure pings once, not endlessly
How It Grew: From Nag to Self-Heal
The evolution is really a story about moving the human further and further out of the loop:
- Stage 1 — Manual: I renewed the token entirely by hand whenever I remembered. Fragile and forgettable.
- Stage 2 — Reminder: a daily check emailed me when the token was aging. Better, but it was a chore-generator that depended on me being reachable.
- Stage 3 — Self-heal: the same daily check now performs the renewal itself and only emails on failure. The manual path still exists as a fallback for the rare day the automation can’t recover.
I kept the manual path deliberately. Self-healing systems shouldn’t delete their own escape hatch — when the automation gives up (correctly, per the safety rails), I want a fast, well-worn way to fix it myself. The failure email is basically a shortcut straight to that path.
What went fast
- The daily freshness check — reading a marker file, comparing an age against a threshold, and deciding whether to act is a handful of lines. The logic is simple; the value is in it running every day forever.
- The short-lived access token refresh — swapping the 30-minute token for a fresh one is the easy half of OAuth and it just worked. The 7-day refresh token was where all the real difficulty lived.
- The failure email — reusing the same Resend setup that powers every other alert on the server. Same pattern, new trigger. Wiring it up took minutes.
What needed patience
- Accepting that renewal needs a real login — I spent time trying to renew the refresh token the “normal” OAuth way before fully accepting Schwab’s 7-day wall is absolute. Once I stopped fighting it and built around it, the design got simpler.
- Making the auto-login safe rather than clever — the instinct is to add retries and make it robust to every hiccup. The scar tissue from a past lockout pushed me the other way: one attempt, hard stop on anything unexpected, hand it to a human. Building restraint took more thought than building capability.
- Getting the early-renewal timing right — renew too late and there’s no buffer to recover; renew too eagerly and you’re driving a login more often than needed. Renewing around day 5 of 7 leaves a two-day cushion without being twitchy.
- Alert discipline — the whole system is only useful if a Schwab email means “act now.” That meant being ruthless about staying silent on success and de-duplicating on failure, so I never train myself to ignore it.
The best part of this one isn’t visible, which is exactly the point. There’s no new feature to look at, no page to open. There’s just a token that used to die every week and now quietly renews itself before it does — and a morning email that keeps arriving with real market data behind it, whether or not I remembered a thing.