etrade-oauth1-signature-flow · v2.0.0 · 2026-08-25 · sha256 60ea008330b96e1d
etrade-oauth1-signature-flow v2.0.0A
Immutable. This exact content is served forever at /api/v1/blob/60ea008330b96e1d.
---
name: etrade-oauth1-signature-flow
description: Use when integrating E*TRADE's API, which uses OAuth 1.0a (not OAuth 2.0)
HMAC-SHA1 request signing — covering the three-legged request-token/authorize/access-token
flow with the mandatory oauth_callback=oob, RFC 5849 signature base string construction
including query parameters, and the 2-hour idle renewal and end-of-day token expiry rules.
domain: algorithmic-trading
subdomain: broker-integration
tags:
- broker-integration
- etrade
- oauth1
- hmac-sha1
- request-signing
- rfc-5849
brokers_frameworks:
- E*TRADE
- OAuth 1.0a (RFC 5849)
- HMAC-SHA1
version: "2.0.0"
author: algo-trading-skills-contributors
license: Apache-2.0
---
## When to Use
Invoke this skill when building a trading bot for E\*TRADE. Unlike most modern broker APIs
that use OAuth 2.0 bearer tokens, E\*TRADE uses **OAuth 1.0a** with HMAC-SHA1 request
signing. Every API request must carry an `Authorization: OAuth ...` header with a nonce,
timestamp, and signature computed over a canonical *signature base string*. This skill
covers the full three-legged flow, per-request signing, and E\*TRADE's two distinct token
lifetime rules.
## When NOT to Use
- **For any other broker.** OAuth 1.0a request signing is specific to E\*TRADE among the
brokers covered here. Schwab uses OAuth 2.0 PKCE (`schwab-api-oauth-pkce-flow`), Upstox
uses refresh-token rotation (`upstox-oauth-refresh-token-rotation`).
- **As a general OAuth 1.0a library.** The signing core here is RFC 5849-conformant, but
`RSA-SHA1` and `PLAINTEXT` are not implemented — E\*TRADE documents `HMAC-SHA1` as the
only supported `oauth_signature_method`.
- **As an HTTP client.** `scripts/etrade_auth.py` deliberately issues no requests and adds
no dependencies. It returns URLs and header values; you supply the transport, timeouts,
and retry policy.
- **As a headless/unattended login.** Leg two requires a human to approve in a browser and
copy back a verifier code. Automating a daily unattended restart is a separate problem —
see `headless-broker-auth-patterns` and `token-lifecycle-live-probing`.
- **As secret storage.** This holds credentials in memory only. Persistence, rotation, and
vaulting belong to `centralized-secrets-management-vault-integration` and
`secrets-rotation-without-bot-downtime`.
## Prerequisites
- E\*TRADE developer account with a consumer key and consumer secret. Sandbox and
production issue **separate** key pairs.
- A browser-capable human for leg two of the flow, once per calendar day.
- Host clock synchronized to within **5 minutes** of E\*TRADE's clock (`oauth_timestamp`
tolerance). See `clock-drift-monitoring-alerting-thresholds`.
## Workflow
1. **Request Token** — `GET {base_url}/oauth/request_token`.
- All four token endpoints are **GET**, not POST.
- **`oauth_callback` is mandatory and must be `"oob"`**, whether or not the app has a
callback URL registered. It has no server-side default; omitting it fails the call.
`build_request_token_header()` sets it.
- Sign with the consumer secret and an **empty token secret** — the signing key still
ends in `&`.
- Parse the form-encoded response with `parse_token_response()`. An E\*TRADE error body
also parses cleanly as form data, so **decision point: reject any response without
both `oauth_token` and `oauth_token_secret` rather than storing empty strings** — an
empty-credential client fails opaquely at the first API call, not here.
2. **User Authorization** — `GET https://us.etrade.com/e/t/etws/authorize?key=…&token=…`.
- **Decision point — this is a different host.** The authorize page is on
`us.etrade.com` for **both** sandbox and production. Deriving it from `api.etrade.com`
or `apisb.etrade.com` yields a dead URL.
- Percent-encode both query values. Request tokens routinely contain `+`, `/`, and `=`;
an unencoded `+` arrives at the server as a space and authorizes nothing.
- The user approves and receives an `oauth_verifier` on the Authorization Complete page
(or appended to a registered callback URL).
- **The request token is valid for 5 minutes.** If the user is slower, restart at leg
one — do not retry the exchange with the stale token.
3. **Access Token** — `GET {base_url}/oauth/access_token`.
- Sign with the consumer secret **and the request token secret**, including
`oauth_token` (the request token) and `oauth_verifier`.
4. **Sign Requests** — build the RFC 5849 base string, then HMAC-SHA1 it.
- Base string URI (§3.4.1.2): lowercase scheme and host, drop the default port, and
**exclude the query and fragment**.
- Parameters (§3.4.1.3.1): the `oauth_*` header parameters **plus the URL's query
parameters plus any form-encoded body parameters**. `oauth_signature` and `realm` are
excluded.
- Normalization (§3.4.1.3.2): percent-encode **first**, then sort by encoded name and,
for repeated names, by encoded value.
- Signing key: `percent_encode(consumer_secret) + "&" + percent_encode(token_secret)`.
- **Decision point — sign the exact URL you will send.** Signing a bare path and then
appending `?detailFlag=ALL` produces a signature E\*TRADE rejects, because the query
parameters were never in the base string.
5. **Token Lifecycle** — two independent rules, with different remedies.
- **Idle inactivation:** after **2 hours** with no API request the access token is
inactivated. Remedy: `GET {base_url}/oauth/renew_access_token`
(`sign_renew_access_token()`).
- **Daily expiry:** the token expires at the **end of the current calendar day, US
Eastern**. Remedy: the full three-legged flow again, with a human. Renewal does not
extend past midnight ET.
- **Decision point — classify the failure before reacting.** A 401 after a quiet period
is an idle inactivation and needs a renew; a 401 after midnight ET needs
re-authorization. Looping renew calls against an expired token never recovers.
- Revoke on shutdown or suspected compromise: `GET {base_url}/oauth/revoke_access_token`
(`sign_revoke_access_token()`).
> Full procedure: see `references/workflows.md`.
> Standards and endpoint table: see `references/standards.md`.
> Printable pre-flight checklist: see `assets/checklist.md`.
## Common Pitfalls
- **Omitting query parameters from the signature base string.** The single most common
cause of a persistent 401 on E\*TRADE: `/v1/accounts/{id}/orders` signs fine, then
`?status=OPEN&count=50` is appended and every call is rejected. Query parameters are a
mandatory signature input (RFC 5849 §3.4.1.3.1), and the base string URI must carry no
query at all.
- **Sorting parameters before percent-encoding.** Raw `"a b" < "a+"` (0x20 < 0x2B), but
encoded `"a%20b" < "a%2B"` orders them the other way. The difference only surfaces on
values containing spaces, `+`, or non-ASCII — so it passes every simple test and fails on
the first symbol list or free-text order field.
- **Omitting `oauth_callback` on leg one.** E\*TRADE requires it on the request-token call
and documents that it must always be `"oob"`. There is no default.
- **Building the authorize URL from the API host.** The authorize page is on
`us.etrade.com`, not `api.etrade.com`/`apisb.etrade.com`, in both environments.
- **Not percent-encoding the request token in the authorize URL.** Base64-ish tokens
contain `+`, `/`, and `=`; an unencoded `+` is decoded server-side as a space.
- **Not percent-encoding the signature in the header.** Base64 output contains `+`, `/`,
and `=`, all of which must be escaped inside the quoted header value.
- **Renewing "before market open" and assuming you are covered.** The 2-hour idle clock is
independent of the trading session. A strategy that places one order at the open and then
goes quiet is inactivated by 11:30 ET even though the token has not expired.
- **Retrying a renew against a token that expired at midnight ET.** Renewal only reverses
idle inactivation. Past end-of-day US Eastern the token is gone and needs a human.
- **Reusing a nonce with the same timestamp.** E\*TRADE rejects it. Generate the nonce and
timestamp fresh per request; never cache a signed header for reuse.
- **Clock skew.** `oauth_timestamp` must be within 5 minutes of E\*TRADE's clock. A drifting
container clock produces signature rejections that look like credential failures.
- **Logging credential objects.** A default dataclass `repr` prints secrets in plain text
into logs and tracebacks. `OAuth1Credentials` and `OAuth1Token` mark secret fields
`repr=False`; keep it that way.
## Verification
- Reproduce the **RFC 5849 §3.4.1.1** published base string with
`build_base_string("POST", "http://example.com/request?b5=%3D%253D&a3=a&c%40=&a2=r%20b", …)`
including the body parameters `c2=` and `a3=2 q` and a `realm` that must be dropped. This
vector exercises query-parameter inclusion, a repeated name, `realm` exclusion, and
encode-then-sort ordering at once.
- Reproduce the **X (Twitter) OAuth 1.0a documented signature** `Ls93hJiZbQ3akF3HF3x1Bz8/zU4=`
from its published base string and signing key, which pins HMAC-SHA1 and base64 end to end.
- Confirm `build_request_token_header()` emits `oauth_callback="oob"` and no `oauth_token`.
- Confirm `get_authorize_url()` starts with `https://us.etrade.com/e/t/etws/authorize?` for
both `use_sandbox=True` and `False`, and that a token `a+b/c=` encodes to `a%2Bb%2Fc%3D`.
- Negative checks: `parse_token_response()` must raise on an empty body, on
`oauth_problem=signature_invalid`, and on a response missing either token field;
`sign_request()` and `sign_renew_access_token()` must raise before an access token is set;
blank consumer credentials must raise at construction.
- Confirm `repr(OAuth1Credentials(...))` contains neither secret.
- Run `python scripts/test_etrade_auth.py` and confirm 100% pass rate.
## Related Skills
- `headless-broker-auth-patterns`
- `schwab-api-oauth-pkce-flow`
- `broker-agnostic-adapter-interface`
- `token-lifecycle-live-probing`
- `clock-drift-monitoring-alerting-thresholds`