When to Use
Use this when authenticating an automated trading bot or market-data client against the Charles Schwab Trader API, including migrations off the retired TD Ameritrade API.
Start with the correction, because most Schwab "PKCE" tooling is built on a false
premise. Schwab's published Trader API documentation describes a
confidential-client authorization-code flow. The authorization URL it documents
is https://api.schwabapi.com/v1/oauth/authorize?client_id={key}&redirect_uri={cb}
— no code_challenge, no code_challenge_method — and the token endpoint
authenticates the client with Authorization: Basic base64(app_key:app_secret).
No Schwab source, and not the most widely used community client (schwab-py),
mentions PKCE anywhere. Implement the documented flow; do not send PKCE parameters
Schwab has never published support for.
And settle the operational question before writing code. Schwab's refresh token is valid for 7 days from creation, refreshing does not extend that window, and Schwab publishes no way to renew it programmatically. A Schwab-connected bot requires a human at a browser at least once a week. That is a scheduling constraint, not a bug to engineer around.
What this skill then covers: building the authorization URL without mangling it, decoding the percent-encoded callback code, exchanging it with strict response validation, persisting tokens as the credentials they are, refreshing on a buffer, and alerting before the 7-day window closes.
When NOT to Use
- For a strategy that cannot tolerate a weekly manual re-authorization. There
is no unattended path past day 7. Choose a different broker for that strategy, or
see
headless-broker-auth-patternsbefore designing around it. - As a general PKCE reference.
SchwabPKCEGeneratoris RFC 7636-correct and usable elsewhere, but PKCE is for public clients that cannot hold a secret. Schwab issues an app secret, so the client is confidential by definition. - As a secrets manager. This persists tokens to a local
0600file. For multi-host or multi-tenant deployments seecentralized-secrets-management-vault-integrationandsecrets-rotation-without-bot-downtime. - As a risk control. Nothing here bounds exposure, drawdown or order rate. See
kill-switch-and-drawdown-circuit-breakersandsec-rule-15c3-5-risk-controls-us. - As an HTTP client. Transport is injected, deliberately, so timeouts, TLS verification and retry policy stay under caller control.
Prerequisites
- A Schwab Developer app in "Ready For Use" state, with its App Key (
client_id) and App Secret. - A registered callback URL that is HTTPS (loopback
https://127.0.0.1is explicitly allowed), under Schwab's 255-character limit, matching byte-for-byte what the client sends. An HTTPS loopback listener needs a self-signed certificate. - A human able to complete the browser login and consent, on a weekly cadence.
- A durable, owner-only path for the token file, outside version control.
- A caller-supplied
http_post_fn(url, form_payload, headers) -> dictthat raises on transport failure.
Workflow
-
Confirm the weekly re-authorization is acceptable, and schedule it.
- Decision point: if a 7-day human step breaks the operating model, stop here — this is a broker-selection problem, not an implementation problem.
- Re-authorizing early is free: a fresh authorization simply starts a new 7-day window. Prefer a planned Sunday pre-market slot over reacting to an alert.
-
Build the authorization URL with encoded parameters and no PKCE.
- Decision point — percent-encode. A raw
redirect_uritruncates the query string at its own?/&; Schwab then compares a mangled callback against the registered one and rejects the login with a security error that names nothing. - Decision point — omit
code_challenge.get_authorization_urlsends it only when a caller passes one explicitly, and warns when they do, because that behaviour is unverified against Schwab.
- Decision point — percent-encode. A raw
-
Capture the callback and URL-decode the code.
- Decision point — check for
errorbefore looking forcode. A denied consent redirects witherror; treating that as "no code yet" hangs instead of failing. - Decision point — the code is percent-encoded and typically ends
%40. Schwab's documentation states the code "must be URL decoded prior to making the request". The community habit of slicing between the literalscode=and%40truncates the trailing@or leaves the value encoded; the exchange then fails with an opaque error.extract_code_from_callbackparses the query string properly.
- Decision point — check for
-
Exchange the code, and validate the response strictly.
POST /v1/oauth/tokenwithAuthorization: Basic base64(app_key:app_secret),Content-Type: application/x-www-form-urlencoded, andgrant_type=authorization_code&code=…&redirect_uri=….- Decision point — never default
expires_in. A client that invents a lifetime the server did not state keeps using a dead token, and every later call 401s for a reason nothing in the logs explains. Absent or non-numeric is fatal. A missingrefresh_tokenis fatal too — unattended operation is impossible without it. - Decision point — a lost response is ambiguous, not a failure. The
authorization code is single-use; Schwab may already have consumed it, in which
case retrying the same code cannot work and the recovery is a fresh browser
authorization.
SchwabAmbiguousTokenErrormarks this and leaves stored state untouched. - Decision point — never interpolate the response into an error message. It
carries
access_token,refresh_tokenandid_token. Echo the OAutherror/error_descriptionand the key names only.
-
Persist tokens as credentials.
- Temp file created at mode
0600before any secret is written,fsync, thenos.replace. A default-mode temp file is briefly world-readable; an unsynced write can leave a truncated file that looks like corruption. - Decision point — a failed write must raise. Logging and continuing leaves
the operator believing the tokens survive a restart.
SchwabTokenPersistenceErroris raised while the in-memory state is kept, so a running process can continue trading and retry the write rather than discarding a token Schwab already issued.
- Temp file created at mode
-
Refresh on a buffer, and never move the 7-day anchor.
- Decision point — refresh at 5 minutes remaining, not on a 401. Refreshing after a rejection puts a token round trip on the critical path of an order.
- Decision point —
refresh_expires_atis anchored at the original authorization. Re-anchoring it on each refresh silences the warning entirely and the bot dies without notice mid-week.refresh_access_tokencarries the original deadline forward unchanged. - Decision point — store a rotated
refresh_tokenif one is returned, and keep the existing one if not. Rotation is undocumented; this is correct either way. - Decision point —
invalid_clientmeans re-authorize, not retry. That is how Schwab rejects an over-age refresh token, and no retry can succeed.SchwabRefreshTokenExpiredErrorexists so an alert binds to exactly that.
-
Alert on the window, and gate the bearer header.
- Poll
is_refresh_token_expiring_soon()hourly; at 24 hours remaining raise an operator alert, not a log line — the remedy needs a human. get_bearer_header()refuses to build a header from a token inside the refresh buffer, so staleness fails locally instead of as a mid-order 401.
- Poll
Full step-by-step procedure: see
references/workflows.md. Sourced endpoints, lifetimes and the PKCE evidence: seereferences/standards.md. Printable pre-flight checklist: seeassets/checklist.md.
Common Pitfalls
- Implementing Schwab as a PKCE flow. It is the premise of a lot of Schwab tooling and no Schwab source supports it. Worse than useless: a caller who thinks PKCE is protecting the exchange may under-protect what actually needs it — the app secret and the token file.
- Interpolating the callback URL into the authorization URL unencoded. It
truncates at the first
?/&and the login fails with a security error. - Hand-slicing the authorization code out of the callback. The code is
percent-encoded and usually ends
%40; slicing oncode=/%40truncates or under-decodes it, and the exchange fails with an error that names nothing. - Assuming
expires_inwhen the response omits it. The client then believes a dead token is live. - Retrying an authorization-code exchange after a timeout. The code is single-use and may already be spent; the recovery is a new browser authorization.
- Re-anchoring the 7-day refresh deadline on every refresh. The warning never fires and the flow dies mid-week with no programmatic recovery.
- Waiting for a 401 before refreshing. The refresh then lands on the critical path of an order submission.
- Treating
invalid_clientas a transient error and retrying. It means the refresh window closed; only a human can fix it. - Discarding stored token state when a refresh fails in transport. Schwab may have rotated the token; throwing away the old one guarantees a re-login that might not have been necessary.
- Writing the token file with default permissions. It holds a live access and refresh token — anyone who can read it can trade the account.
- Logging or
repr-ing token state. Onelogger.debug(state)ships credentials to the log aggregator; tokens are excluded fromreprfor this reason. - Putting the token response in an exception message. Three credentials go straight into the traceback.
- Swallowing a token-file write failure. The process looks healthy until it restarts and finds nothing.
- Letting two processes share one token file. The write is atomic but takes no cross-process lock, so concurrent refreshes are last-writer-wins — and if Schwab rotates the refresh token, the loser holds a stale one and forces an unplanned re-login. Run exactly one token owner per Schwab app and have other processes read the token, never refresh it.
- Quoting an unsourced overall rate limit as a Schwab contract. Schwab documents a 0–120 requests/minute order throttle; the commonly cited overall figure is community-reported.
Verification
- Authorization URL: parameters are percent-encoded (a
redirect_uricontaining:and/never appears literally); nocode_challengeorcode_challenge_methodis present by default; both appear only when a challenge is explicitly supplied; a padded challenge, a non-HTTPS callback, an over-length callback and a blank app key each raise. - Callback decoding:
?code=C0.abc-def%40yieldsC0.abc-def@;%2Byields+, not a space; anerrorparameter raises; zero, empty or duplicatedcodeparameters raise. - Exchange request shape: posts to
/v1/oauth/tokenwithgrant_type=authorization_code, the decoded code, the callback,Authorization: Basic base64("KEY:SECRET")and the form content type; nocode_verifierunless supplied; a colon in the app key raises before dispatch. - Exchange response validation: missing
expires_inraises and leaves state unset;None,"soon",0,-5,True,infandNaNall raise; a missingrefresh_tokenraises. - Secret hygiene: a rejection carrying
refresh_token/id_tokenproduces a message containing the OAuth error but neither credential;repr()of token state shows the expiry fields and neither token. - Ambiguity: a transport exception and a non-JSON-object body each raise
SchwabAmbiguousTokenErrorand leave prior state identical. - Refresh: the 7-day deadline is byte-identical before and after a refresh (the
regression); the request body is exactly
grant_type/refresh_token; a rotated token is stored and reaches disk; an absent one keeps the existing value; an elapsed window and a missing token both raiseSchwabRefreshTokenExpiredErrorwith zero network calls;error=invalid_clientraises the same; a transport failure preserves state. - Lifetimes: the access buffer boundary flips at exactly
expiry - 300 s, the refresh warning at exactlyexpiry - 86400 s; no state counts as expiring;get_bearer_header()raises inside the buffer and returns the Bearer header outside it. - Persistence: state round-trips through a new manager; no
.tmpfiles remain; the file is0600on POSIX; a corrupt or wrongly typed file yieldsNonestate without crashing and is left on disk for the operator; an unwritable path raisesSchwabTokenPersistenceErrorwhile the in-memory token stays usable. - RFC 7636 helper: the Appendix B vector
(
dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk→E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM) reproduces exactly; challenges are 43 characters and unpadded; lengths 42 and 129 raise while 43 and 128 succeed. - Run
python -m unittest discover -s skills/schwab-api-oauth-pkce-flow/scriptsand confirm all tests pass.