..

Using Unofficial Twitter/X API (Part 1): How to Authenticate with Go

read this /// disclaimer

This project is an UNOFFICIAL library for interacting with Twitter/X’s internal APIs. It is NOT affiliated with, endorsed, or sponsored by Twitter, Inc. or X Corp.

Using this library may violate Twitter/X’s Terms of Service. This can lead to actions against your account, including but not limited to rate-limiting, suspension, or permanent banning.

repo

right here

intro

This blog post is about Helicon, a project that I started quite a while ago and didn’t go so far after authentication. Now that I am picking up this project again, I wanted to write a blog post about the progression and share how the authentication flow works.

why?

Why Helicon, why unofficial API? One and only reason: official API pricing. $100 is far too steep for an API that once was completely free. You shouldn’t pay $100 for posting Peugeot 206 pics daily, and besides all fun things you could do with gimmick accounts, X API is not worth $100. The value of content you get out of $100 is not even comparable to other price-gated trade or astronomy APIs.

Also, people are paying “unofficial X API resellers” way too much money - like $0.10 per 1000 tweet information. The unofficial API has no rate limits, no restrictions, all you need is an army of bot accounts to become a reseller. And the GraphQL API is not top secret information! It’s not like “unofficial API is too hard to use, I must pay resellers” - no, it’s actually pretty easy!

how?

We will (try to) interact with the unofficial API using our library, Helicon. Let’s begin with the most fundamental and often the trickiest part: Authentication. Unlike traditional APIs that might use simple API keys or OAuth flows, Twitter/X’s internal mechanisms are a bit different.

more than just a login

Authenticating with X API isn’t as simple as sending tokens - “now you got the callback, here are your access tokens, etc.” No, the unofficial API is more than just a normal OAuth2 flow.

here is a general overview of how authentication with unofficial API looks:

+--------------------------+          +------------------------+
| 1. Find Anonymous Bearer |<--------->| X.com Login Page (HTML)|
|    Token & Guest Token   |          +------------------------+
+--------------------------+
             |                                    |
             |                                    |
             V                                    V
+--------------------------+          +------------------------+
| 2. Start Login Flow      |<--------->| X.com Main JS File     |
|    (with anon token,     |          | (Contains Bearer Token)|
|    guest token)          |          +------------------------+
+--------------------------+
             |
             | (Receives JS Challenge URL)
             V
+--------------------------+
| 3. Solve JS Challenge    |
|    (headless browser)    |
+--------------------------+
             |
             | (Submits Solution)
             V
+--------------------------+
| 4. Submit Username       |
+--------------------------+
             |
             |
             V
+--------------------------+
| 5. Submit Password       |
|    (captures auth_token, |
|    ct0 cookies)          |
+--------------------------+
             |
             |
             V
+--------------------------+
| 6. Save Tokens to Keyring|
+--------------------------+

initial: anonymous token

Before we can even think about logging in, we need two tokens that grant us basic access to the Twitter/X API endpoints: an Anonymous Bearer Token and a Guest Token.

The Anonymous Bearer Token is a static token embedded within Twitter/X’s main JavaScript bundle. It’s used for initial client-to-server communication, even before a user logs in.

at our library code, FindTwitterMainJavascriptUrl function scrapes the login page HTML to find the URL of this dynamic JavaScript file:

func (h *Helicon) FindTwitterMainJavascriptUrl() (*string, error) {
	req, _ := http.NewRequest("GET", "https://x.com/i/flow/login/", nil)
	req.Header.Set("User-Agent", h.UserAgent)
	resp, _ := http.DefaultClient.Do(req)
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	htmlContent := string(body)

	re := regexp.MustCompile(`src=["'](https://abs\.twimg\.com/responsive-web/client-web-legacy/main\.[\w.-]+\.js)["']`)
	matches := re.FindStringSubmatch(htmlContent)
	if len(matches) == 0 {
		re = regexp.MustCompile(`src=["'](https://abs\.twimg\.com/responsive-web/client-web/main\.[\w.-]+\.js)["']`)
		matches = re.FindStringSubmatch(htmlContent)
	}

	scriptUri := strings.TrimPrefix(strings.TrimSuffix(matches[0], `"`), `src="`)
	return &scriptUri, nil
}

Once found, FindAnonymousBearerToken then fetches JS file and extracts the Bearer token:

func (h *Helicon) FindAnonymousBearerToken() (*string, error) {
	mainScriptUrl, _ := h.FindTwitterMainJavascriptUrl()
	req, _ := http.NewRequest("GET", *mainScriptUrl, nil)
	req.Header.Set("User-Agent", h.UserAgent)
	resp, _ := http.DefaultClient.Do(req)
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	re := regexp.MustCompile("(Bearer)(.*?)(\"| \\z)")
	matches := re.FindAll(body, -1)
	match := string(matches[len(matches)-1])
	match = strings.TrimPrefix(strings.TrimSuffix(match, `"`), `"`)
	return &match, nil
}

Similarly, the Guest Token is obtained by making an initial request to the login page and parsing the HTML for the cookie assignment:

func (h *Helicon) GenerateGuestToken() (*string, error) {
	req, _ := http.NewRequest("GET", "https://x.com/i/flow/login/", nil)
	req.Header.Set("User-Agent", h.UserAgent)
	resp, _ := http.DefaultClient.Do(req)
	defer resp.Body.Close()

	body, _ := io.ReadAll(resp.Body)
	htmlContent := string(body)
	re := regexp.MustCompile(`document\.cookie="gt=([0-9]+)`)
	matches := re.FindStringSubmatch(htmlContent)
	return &matches[1], nil
}

now we have two necessary tokens in hand, lets proceed with https://api.x.com/1.1/onboarding/task.json, main authentication endpoint.

auth flow

The authentication flow is done through one single endpoint, task.json. This endpoint handles the entire login process through a series of subtasks.

{
	"input_flow_data": {
		"flow_context": {
			"debug_overrides": {},
			"start_location": {
				"location": "manual_link"
			}
		}
	}
}

The login flow is initiated with a POST request to the task endpoint:

func (h *Helicon) StartLoginFlow() (*LoginFlow, error) {
	anonymousToken, _ := h.FindAnonymousBearerToken()
	guestId, _ := h.GenerateGuestToken()

	body := `{
		"input_flow_data": {
			"flow_context": {
				"debug_overrides": {},
				"start_location": {
					"location": "manual_link"
				}
			}
		}
	}`

	req, _ := http.NewRequest(http.MethodPost, "https://api.x.com/1.1/onboarding/task.json", strings.NewReader(body))
	q := req.URL.Query()
	q.Set("flow_name", "login")
	req.URL.RawQuery = q.Encode()

	req.Header.Set("Authorization", *anonymousToken)
	req.Header.Set("x-guest-token", *guestId)
	req.Header.Set("User-Agent", h.UserAgent)
	req.Header.Set("Content-Type", "application/json")

	resp, _ := http.DefaultClient.Do(req)
	// Parse response into LoginFlow struct...
}

afterwards, initiated flow details will be sent from X in the format of:

type LoginFlow struct {
	FlowToken string `json:"flow_token"`
	Status    string `json:"status"`
	Subtasks  []struct {
		SubtaskId         string `json:"subtask_id"`
		JsInstrumentation struct {
			Url       string `json:"url"`
			TimeoutMs int    `json:"timeout_ms"`
			NextLink  struct {
				LinkType string `json:"link_type"`
				LinkId   string `json:"link_id"`
			} `json:"next_link"`
		} `json:"js_instrumentation"`
	} `json:"subtasks"`
	AnonymousBearerToken string
	GuestToken           string
	GuestId              string
	UserAgent            string
	Att                  string
	CFBM                 string
}

anti-bot challenge

when we start the login flow, X responds with a JsInstrumentationSubtask, as a anti-bot measurement, providing a URL to challenge JS file. this script, when executed in a browser environment, computes a unique “solution” string (often an ui_metrics value) that must be submitted back to the API.

we can’t just eval this JS in Go and we cant use JS engines, we need a full browser environment due to dependency on document API. we will use chromedp, a Go library for controlling Chrome DevTools Protocol, to spawn a headless Chrome instance.

Here’s how the JS challenge is solved:

func (f *LoginFlow) solveJSInstrumentationChallenge(userAgent string) (*string, error) {
	// Fetch the challenge script from X's servers
	target := f.Subtasks[0].JsInstrumentation.Url
	resp, _ := http.Get(target)
	defer resp.Body.Close()
	script, _ := io.ReadAll(resp.Body)
	scriptContent := string(script)

	// Setup headless Chrome
	allocOpts := append(chromedp.DefaultExecAllocatorOptions[:],
		chromedp.Flag("headless", true),
		chromedp.Flag("disable-gpu", true),
		chromedp.UserAgent(userAgent),
		chromedp.NoSandbox,
	)
	allocCtx, cancelAlloc := chromedp.NewExecAllocator(context.Background(), allocOpts...)
	defer cancelAlloc()
	taskCtx, cancelTask := chromedp.NewContext(allocCtx)
	defer cancelTask()

The core technique involves overriding document.getElementsByName to capture the ui_metrics value:

	jsToEvaluate := fmt.Sprintf(`
		(() => {
			return new Promise((resolve, reject) => {
				let resultToCapture;
				const originalGetElementsByName = document.getElementsByName;
				document.getElementsByName = function(name) {
					if (name === 'ui_metrics') {
						return [{ 
							set value(val) {
								resultToCapture = val;
								resolve(resultToCapture);
							}
						}];
					}
					return originalGetElementsByName.apply(this, arguments);
				};
				eval(%s); // Execute the challenge script
			});
		})();
	`, "`"+scriptContent+"`")

	// Execute in browser and return the captured ui_metrics value
	chromedp.Run(taskCtx, chromedp.Evaluate(jsToEvaluate, &evaluationResult))
	return &result, nil
}

submit credentials and cookies

from this point and on its fairly easy. all we need to do is sending credentials:

func (f *LoginFlow) SubmitUsernameAndPassword(helicon *Helicon) error {
	// First, submit username
	submitUsernameBody := SubmitUsernameRequest{
		FlowToken: f.FlowToken,
		SubtaskInputs: []SubtaskInputs{{
			SubtaskID: "LoginEnterUserIdentifierSSO",
			SettingsList: SettingsList{
				Link: "next_link",
				SettingResponses: []SettingResponses{{
					Key: "user_identifier",
					ResponseData: ResponseData{
						TextData: TextData{
							Result: helicon.Credentials.Username,
						},
					},
				}},
			},
		}},
	}

	// Submit username and get response
	req, _ := http.NewRequest(http.MethodPost, "https://api.x.com/1.1/onboarding/task.json", body)
	req.Header.Set("Authorization", f.AnonymousBearerToken)
	req.Header.Set("x-guest-token", f.GuestToken)
	resp, _ := http.DefaultClient.Do(req)
	// Parse response...

Then submit the password in a similar fashion:

	// Submit password
	submitPasswordRequest := SubmitPasswordRequest{
		FlowToken: submitUsernameResponse.FlowToken,
		SubtaskInputs: []SubmitPasswordSubtaskInput{{
			EnterPassword: EnterPassword{
				Link:     "next_link",
				Password: helicon.Credentials.Password,
			},
			SubtaskID: "LoginEnterPassword",
		}},
	}

	// Submit password and capture session cookies
	req, _ = http.NewRequest(http.MethodPost, "https://api.x.com/1.1/onboarding/task.json", body)
	resp, _ = http.DefaultClient.Do(req)
	// Extract auth_token and ct0 from Set-Cookie headers
}

success?

upon a successful password submission, Twitter/X returns session cookies in the Set-Cookie headers of the HTTP response. Specifically, we need:

  • ct0: This is your CSRF (Cross-Site Request Forgery) token, vital for making subsequent authenticated requests.
  • auth_token: This is your primary session token, indicating you are logged in.

These tokens are then saved to the system keyring for future use:

// Extract cookies from response headers
for _, cookie := range resp.Cookies() {
	switch cookie.Name {
	case "ct0":
		h.Credentials.CSRFToken = cookie.Value
	case "auth_token":
		h.Credentials.AuthToken = cookie.Value
	}
}

// Save to keyring for persistence
func (h *Helicon) SaveTokensToKeyring() error {
	pass := fmt.Sprintf("%s|%s|%s", 
		h.Credentials.CSRFToken, 
		h.Credentials.BearerToken, 
		h.Credentials.AuthToken)
	return keyring.Set("helicon", h.Credentials.Username, pass)
}

next steps

With these tokens in hand, you can now make authenticated requests to Twitter/X’s internal GraphQL endpoints.

wait, how???

well, thats the hardest part, and, hopefully I will cover this sooner or later.

but I have an example for you! which you can access here

hitting API itself is not the hardest part. all you have to do is setting headers properly:

// anonymous bearer token
// dont mix with your own auth token
req.Header.Set("Authorization", h.Cookies.BearerToken)
req.Header.Set("X-Csrf-Token", h.Cookies.CSRFToken.Value)
req.Header.Set("X-Twitter-Auth-Type", "OAuth2Session")
req.Header.Set("X-Twitter-Active-User", "yes")
req.Header.Set("X-Twitter-Client-Language", "en")
req.Header.Set("Cookie", fmt.Sprintf("auth_token=%s; ct0=%s", h.Cookies.AuthToken.Value, h.Cookies.CSRFToken.Value))
req.Header.Set("Accept", "*/*")
req.Header.Set("User-Agent", h.UserAgent)

hardest part is constructing the URL for request because GQL is one giant mess.

func (h *Helicon) hitApi(url string) ([]byte, error) {
	req, _ := http.NewRequest(http.MethodGet, url, nil)
	// see the code block above
	h.setCommonHeaders(req)
	resp, _ := http.DefaultClient.Do(req)
	var respBody []byte
	respBody, _ = io.ReadAll(resp.Body)
	return respBody, nil
}

this is all you need to hit the API. set headers and you are good to go. but.

internal GQL calls are shaped like this

https://x.com/i/api/graphql/XXXX/TweetDetail

where the X is GraphQL query ID. Thats the hardest part. Its not a static query ID. With every update query ID changes. Good news is that, query IDs are also stored at main JS bundle so all we need is 600 lines of regex and some black magic to find all GQL ids.

I highly recommend you to read this file to understand how messy API request // responses are: read me pls

closing

This is how far I have got with the library, ill start working on it again. Full implementation is available in the Helicon repository - feel free to contribute or report issues!