April 2026
I have a self-hosted E*TRADE order scheduler that needs to log in every weekday morning. E*TRADE requires 2FA via SMS every time. I got tired of waking up at 6:30 AM to type in a verification code, so I automated the entire thing. I finished at 3 AM.
The Problem
E*TRADE sends a verification code via text message during login. My trading app runs on a Mac Mini (ryansoldmac) at home, and it needs that code to complete authentication. The app already has a /api/auth/auto endpoint that starts the login flow and a /api/auth/auto/webhook endpoint that accepts the OTP — I just needed a way to get the code from my phone to the webhook automatically.
The home Mac's local IP can change. I can't just point Google at a LAN address.
The Solution
Google Voice SMS → Gmail → Apps Script → Caddy reverse proxy → Tailscale → local relay → app webhook
Google Voice forwards texts to Gmail. A Google Apps Script polls Gmail for E*TRADE verification emails, extracts the code, and POSTs it to a public HTTPS endpoint. That endpoint is a Caddy reverse proxy on my private server, which forwards the request through my self-hosted Tailscale network (headscale) to a tiny Node.js relay running on the Mac Mini. The relay validates a shared secret and passes the OTP to the app.
Step 1: The Relay Script
The relay is a ~60-line Node.js HTTP server (otp-webhook-relay.mjs) that:
- Listens on port 3102
- Accepts POSTs with an
x-webhook-secretheader - Validates the secret against a shared key
- Forwards the request body to
http://127.0.0.1:3001/api/auth/auto/webhook
It's intentionally minimal — the only thing exposed to the internet, and it only forwards to localhost.
Step 2: The Apps Script
The Apps Script (etrade-gmail-webhook.gs) runs on a per-minute trigger between 6:30 and 7:30 AM Eastern. It:
- Searches Gmail for recent E*TRADE verification emails
- Extracts the SMS body
- POSTs it to the webhook URL with the shared secret
- Labels the email as processed so it doesn't re-send
Script properties hold the webhook URL, the shared secret, the Gmail search query, the time window, and the processed label name.
Gotcha: in:inbox and auto-archiving. The default Gmail search query uses in:inbox, which sounds fine until you realize your Gmail filters might be auto-archiving E*TRADE emails before the script ever sees them. If you have a filter that skips the inbox (labels them and archives), the script will poll happily and never find anything. The fix is to either remove in:inbox from the query entirely, or replace it with is:anywhere so it searches all mail regardless of where it landed.
Step 3: Tailscale + Headscale
My machines are connected via a self-hosted headscale server running on my private VPS. The Mac Mini is on the tailnet as ryansoldmac. The relay binds to 0.0.0.0:3102 so it's reachable from other tailnet nodes.
Tailscale Funnel — the right way (that I haven't finished)
The correct approach is Tailscale Funnel. It would give ryansoldmac a stable public HTTPS URL like ryansoldmac.<my-tailnet-domain> with traffic routed through Tailscale's DERP relay infrastructure directly to the Mac Mini. No middleman server, no single point of failure, works regardless of the home IP.
But Funnel requires HTTPS certificate provisioning for nodes, which means configuring ACME/Let's Encrypt in headscale, setting up wildcard DNS for the tailnet domain, and updating the ACL policy. When I tried tailscale funnel --bg localhost:3102, I got:
Funnel not available; HTTPS must be enabled.
It was past 3 AM and I needed a working solution, not a perfect one. I'll circle back to this.
The workaround: Caddy reverse proxy
Instead of Funnel, I added a Caddy reverse proxy entry on my private server. Caddy already runs there serving several sites with automatic Let's Encrypt TLS. I:
- Created a DNS A record for a new subdomain pointing to my private server
-
Added a reverse proxy block to the Caddyfile:
relay.example.com { log { ... } reverse_proxy 100.64.0.7:3102 }(where
100.64.0.7is ryansoldmac's Tailscale IP) - Reloaded Caddy — it automatically provisioned a TLS cert
The traffic path is: Google Apps Script → my private server → tailnet → ryansoldmac:3102. It adds the VPS as a single point of failure compared to Funnel, but it works.
Step 4: Cron
A simple crontab entry on the Mac Mini kicks off the login at 6:30 AM on weekdays:
30 6 * * 1-5 curl -sS -X POST http://127.0.0.1:3001/api/auth/auto \
-H 'Content-Type: application/json' \
-d '{"headless":true,"clearCookies":true}'
This starts the E*TRADE login flow. E*TRADE sends the SMS. Google Voice forwards it to Gmail. Apps Script picks it up within a minute and POSTs the code to the relay. Login completes automatically.
Step 5: Creator Script
I wrote a bash script (create-otp-relay-setup.sh) that generates all the deployment artifacts: the relay's env file, a systemd service unit (for Linux — the Mac needs a launchd plist instead), install instructions, and the Funnel commands. It can also auto-update the app's .env with the shared secret.
./create-otp-relay-setup.sh --secret 'your-secret-here' --update-dotenv
On Linux it supports --apply to install the systemd service and --enable-funnel to set up Tailscale Funnel. On macOS, the --apply flag fails because there's no systemctl — something I need to fix.
Step 6: launchd (Surviving Reboots on macOS)
The relay started as a nohup background process, which died every time the SSH session disconnected. Even with disown, it was fragile. The proper macOS solution is a launchd plist.
I created ~/Library/LaunchAgents/com.etrade.otp-relay.plist:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.etrade.otp-relay</string>
<key>ProgramArguments</key>
<array>
<string>/path/to/node</string>
<string>/path/to/otp-webhook-relay.mjs</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>RELAY_PORT</key>
<string>3102</string>
<key>RELAY_HOST</key>
<string>0.0.0.0</string>
<key>RELAY_UPSTREAM_URL</key>
<string>http://127.0.0.1:3001/api/auth/auto/webhook</string>
<key>RELAY_SHARED_SECRET</key>
<string>your-secret-here</string>
</dict>
<key>KeepAlive</key>
<true/>
<key>RunAtLoad</key>
<true/>
<key>StandardOutPath</key>
<string>/tmp/otp-relay.log</string>
<key>StandardErrorPath</key>
<string>/tmp/otp-relay.log</string>
</dict>
</plist>
KeepAlive means launchd restarts it if it crashes. RunAtLoad starts it on login. Load it with:
launchctl load ~/Library/LaunchAgents/com.etrade.otp-relay.plist
One gotcha: RELAY_HOST must be 0.0.0.0, not 127.0.0.1. The Caddy reverse proxy on my private server connects via the Tailscale IP, not localhost. If the relay only binds to localhost, the tailnet traffic gets refused — I spent 20 minutes debugging a 502 before catching that.
launchd works for the relay but not for the main app. The relay plist works because it calls node directly with all its config in environment variables. The server needs 27 env vars from .env (database URL, OAuth tokens, API keys...) and runs from a project directory inside ~/Documents. macOS TCC (Transparency, Consent, and Control) blocks launchd processes from accessing the Documents folder — even if you own it. Every attempt gave EPERM: uv_cwd or Operation not permitted.
The fix: screen. For the server and frontend, I use screen -dmS to create detached sessions that survive SSH disconnects:
screen -dmS etrade-server bash -c \
'cd /path/to/project && source .env && export $(grep -v "^#" .env | xargs) && exec npx tsx src/server/index.ts'
screen -dmS etrade-frontend bash -c \
'cd /path/to/project && exec npx vite --host --port 3000'
screen sessions persist because they're managed by the screen daemon, not tied to the SSH connection. The morning cron script checks whether each service is up and starts it in a screen session if not.
Testing
Before going to bed, I tested each piece of the pipeline independently.
Relay responds locally:
$ curl -sS http://127.0.0.1:3102/
{"success":false,"error":"POST only"}
Public URL reaches the relay through Caddy + tailnet:
$ curl -sS https://relay.example.com/
{"success":false,"error":"POST only"}
Full POST with secret reaches the relay and tries to forward to the app:
$ curl -sS -X POST "https://relay.example.com/" \
-H 'Content-Type: application/json' \
-H 'x-webhook-secret: your-secret-here' \
-d '{"Body":"Your E*TRADE verification code is 123456."}'
{"success":false,"error":"fetch failed"}
The "fetch failed" is expected — the trading app wasn't running at 4 AM, so the relay couldn't forward to localhost:3001. But the request made it all the way from the public internet to the relay on my home Mac through the tailnet, which is the part I needed to verify.
When the app is running, that last hop completes and the OTP gets submitted.
Then at 4:43 AM, it all came together. I triggered a real auth, E*TRADE sent the SMS, Google Voice forwarded it to Gmail, and the Apps Script picked it up on its next poll. The webhook delivered code 55****, the app entered it into the Puppeteer browser session, completed the OAuth flow, and saved the tokens — fully unattended. The successful run took 29 seconds because the app was doing the full Puppeteer dance (entering the code, waiting for E*TRADE to verify, clicking through OAuth). Subsequent polls dropped back to under 1.5 seconds since there was nothing left to deliver.
Before that worked, I hit two more issues:
Stale dist build. The app was running with node dist/server/index.js, but the dist was built before the webhook endpoint was added. Apps Script got a 404. The TypeScript source had errors that prevented a clean tsc build, so I switched to running the source directly with tsx — npx tsx src/server/index.ts. That skips type checking and just runs it.
Wrong-architecture esbuild. The node_modules directory was synced from a Linux server (H2), so the esbuild binary was for the wrong platform. npm rebuild esbuild fixed it.
I actually hit one more issue during testing: the Apps Script was triggering on schedule and polling Gmail, but it wasn't finding any emails. Turns out I had a Gmail filter that auto-archived E*TRADE notifications on arrival, so they never sat in the inbox. The script's search query used in:inbox, which meant it was looking in the one place the emails weren't. Swapping to is:anywhere fixed it immediately. (See the gotcha note in Step 2 above.)
What's Left
- Tailscale Funnel: Configure headscale for node HTTPS certs so I can use Funnel instead of the Caddy workaround. This removes the VPS as a dependency.
- Fix the TypeScript build: The
dist/is stale andtschas errors. Currently running withtsx(dev mode), which works but is slower to start than the compiled version. - macOS support in the creator script: The
--applyflag assumes Linux/systemd. Should detect macOS and generate a launchd plist instead. - Monitoring: No alerting if the relay goes down or the Apps Script trigger stops firing.
The Morning Script
Instead of separate cron entries, a single etrade-morning.sh runs at 6:25 AM and handles everything in order:
- Check if the server is running; if not, start it in a screen session (wait 10s for startup)
- Check if the frontend is running; if not, start it in a screen session
- Check if the scheduler is running; if not, start it in a screen session
- Trigger the E*TRADE auth flow and wait 3 minutes for the OTP relay to deliver the code
- Log the current Micron quote
- Log the INTC options chain
25 6 * * 1-5 /path/to/etrade-morning.sh >> /tmp/etrade-morning.log 2>&1
The Full Flow
Every weekday at 6:25 AM:
- Cron fires
curl→ app starts E*TRADE login → E*TRADE sends SMS - Google Voice forwards the text to Gmail
- Apps Script (running every minute, 6:30–7:30 AM) finds the email
- Apps Script POSTs the SMS body to the public relay URL with the shared secret
- Caddy on my private server forwards through the tailnet to ryansoldmac:3102
- The relay validates the secret and forwards to
127.0.0.1:3001/api/auth/auto/webhook - The app extracts the OTP from the SMS body and completes login
Total latency from SMS to login: under 2 minutes, mostly waiting for Apps Script's polling interval. The actual webhook delivery + Puppeteer login takes about 30 seconds. I sleep through the whole thing.
I finished at 4:50 AM.
Disclaimer: This is not financial advice. Automating brokerage authentication carries risks. Shared secrets should be stored securely and rotated regularly. This setup is shared for educational purposes.
Enjoyed this post?
Get notified when I publish something new. No spam, unsubscribe anytime.