Day 1: Migrating from session based authentication to OAuth2 for The Wheel Screener's historical data fetcher
Fixing a soon-to-be removed authentication method to a more secure and modern OAuth2 flow
Written by Chris on December 1st, 2025
This post is part of the 'The 12 Days of Full Stack Dev' series, an adventure through full stack fintech app development. Join me through the first 12 days of December as we develop a variety of new features, fixes, and components within the Full Stack Craft fintech family.
For the full list of days and topics in this series, see the original post: The 12 Days of Full Stack Dev.
The Challenge
TastyTrade recently announced they're deprecating their legacy session-based authentication in favor of OAuth2. This meant I needed to migrate The Wheel Screener's historical data fetcher from the old remember_token system to the new OAuth2 refresh token flow. While breaking changes are never fun (and stressful because of the fixed deadline - November 5th!), this update actually simplifies our authentication logic considerably.
What Changed?
The old system was a multi-layered approach:
- Store session tokens and remember tokens in a local file
- Check if the session is expiring within an hour
- If expired, try to refresh using the remember token
- If that fails, fall back to username/password authentication
- Persist everything back to disk
The new system is much cleaner:
- Store an OAuth2 refresh token in environment variables
- Use it to get a short-lived access token
- Keep the access token in memory
- Refresh when needed (every 5 minutes or so, ensuring we refresh before the 15 minute expiry)
The Migration
Before: Session-Based Authentication
The old code maintained a complex session lifecycle with file-based persistence:
// LoginWithNewSessionAsFallback checks if an existing session is expiring
func LoginWithNewSessionAsFallback() error {
username := os.Getenv("TASTYTRADE_USERNAME")
password := os.Getenv("TASTYTRADE_PASSWORD")
// Attempt to read an existing session
si, err := readSessionFromFile()
if err == nil {
// Check if the session token is expiring in less than an hour
if time.Now().Add(SessionRefreshThreshold).Before(si.SessionExpiration) && si.SessionToken != "" {
return nil
}
// Try remember token if available
if si.RememberToken != "" && si.Username != "" {
newSI, err := createSession(si.Username, "", si.RememberToken, true)
if err == nil && newSI.SessionToken != "" {
return saveSessionToFile(newSI)
}
}
}
// Fall back to password authentication
log.Printf("Token expired; logging in with email %s", username)
newSI, err := loginWithUsernameAndPassword(username, password)
if err != nil {
return err
}
return saveSessionToFile(newSI)
}
After: OAuth2 Refresh Tokens
The new implementation is dramatically simpler:
// pointer to hold auth token in memory
var authTokenPointer *string
// auth token response structure
type TastytradeOauthResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
ExpiresIn int `json:"expires_in"`
Scope string `json:"scope"`
}
func LoginWithRefreshToken() error {
refreshToken := os.Getenv("TASTYTRADE_REFRESH_TOKEN")
if refreshToken == "" {
return errors.New("TASTYTRADE_REFRESH_TOKEN must be set in environment")
}
clientSecret := os.Getenv("TASTYTRADE_CLIENT_SECRET")
if clientSecret == "" {
return errors.New("TASTYTRADE_CLIENT_SECRET must be set in environment")
}
accessToken, err := getAccessTokenUsingRefreshToken(refreshToken, clientSecret)
if err != nil {
return err
}
// store in memory
authTokenPointer = &accessToken
return nil
}
The Token Exchange
The heart of the OAuth2 flow is refreshingly straightforward:
func getAccessTokenUsingRefreshToken(refreshToken, clientSecret string) (string, error) {
bodyData := map[string]string{
"grant_type": "refresh_token",
"refresh_token": refreshToken,
"client_secret": clientSecret,
}
jsonBytes, err := json.Marshal(bodyData)
if err != nil {
return "", err
}
headers := map[string]string{
"Content-Type": "application/json",
}
params := url.Values{}
respBytes, err := http_helper.MakeHTTPRequest(
TastytradeOauthURL,
"POST",
headers,
params,
bytes.NewReader(jsonBytes),
false,
)
if err != nil {
return "", err
}
var oauthResponse TastytradeOauthResponse
if err := json.Unmarshal(respBytes, &oauthResponse); err != nil {
return "", err
}
if oauthResponse.AccessToken == "" {
return "", errors.New("failed to obtain access token from Tastytrade OAuth")
}
return oauthResponse.AccessToken, nil
}
Simplified Token Management
The new ensureAuthToken() function replaces the old ensureSession() with much cleaner logic:
func ensureAuthToken() (string, error) {
// if we have a valid auth token in memory, return it
if authTokenPointer != nil {
return *authTokenPointer, nil
}
// otherwise, try to get a new one using refresh token
refreshToken := os.Getenv("TASTYTRADE_REFRESH_TOKEN")
if refreshToken == "" {
return "", errors.New("TASTYTRADE_REFRESH_TOKEN must be set in environment")
}
clientSecret := os.Getenv("TASTYTRADE_CLIENT_SECRET")
if clientSecret == "" {
return "", errors.New("TASTYTRADE_CLIENT_SECRET must be set in environment")
}
accessToken, err := getAccessTokenUsingRefreshToken(refreshToken, clientSecret)
if err != nil {
return "", err
}
// store in memory
authTokenPointer = &accessToken
return accessToken, nil
}
Updated API Calls
The actual API calls now use proper Bearer token authentication:
// Headers (include Authorization with Bearer token)
headers := map[string]string{
"Authorization": "Bearer " + authToken,
}
What Got Removed?
I was able to delete a significant amount of code:
- ~100 lines of session management logic removed
readSessionFromFile()function - no more disk I/OsaveSessionToFile()function - no more file persistenceloginWithUsernameAndPassword()- no more password handlingcreateSession()- simplified to OAuth2 flow- Complex expiration checking and token refresh logic
Thanks for Reading!
Thanks and see you in day 2 of the series!
The Wheel Screener is an option screener optimized for the wheel strategy. Learn more at wheelscreener.com