Login flow
X migrated web login off the classic onboarding/task.json subtask flow onto Jetfuel. x.com/i/flow/login now redirects to x.com/i/jf/onboarding/web?mode=login, and the login state machine runs as Jetfuel server-driven UI over the jfapi transport.
The classic flow is dead: it hard-fails with error 366 ("flow name LoginFlow is currently not accessible") on the TweetDeck bearer, and 399 ("Could not log you in now") on the web bearer. emusks drives the Jetfuel flow fully headlessly (pure HTTP, no browser): it mints the Castle device token in-process (see The Castle gate).
const client = new Emusks();
await client.login({
type: "password",
username: "your_handle", // or email
password: "your_password",
onRequest: async (type) => {
if (type === "email_code") return await readEmailCode();
if (type === "two_factor_code") return await readTotp();
},
});Flow options
flow | Transport | Status |
|---|---|---|
"jetfuel" (default) | headless jfapi over cycletls; Castle minted in-process | works headlessly, no browser |
"classic" | old onboarding/task.json | dead, kept for reference |
Supply getCastleToken(action) to override the built-in minter with your own token source (e.g. a Castle solver service):
await client.login({ type: "password", username, password, getCastleToken: (action) => myCastleSolver(action) });The wire flow
All calls are unauthenticated (guest token) until auth_token lands in the cookie jar.
| Step | Request |
|---|---|
| 1. Guest token | POST https://api.x.com/1.1/guest/activate.json (web bearer) |
| 2. Landing | GET https://x.com/i/jfapi/onboarding/web?mode=login → Jetfuel page defining the form + first action |
| 3. Identifier | POST https://x.com/i/jfapi/onboarding/web/actions/begin_login |
| 4. Password | POST https://x.com/i/jfapi/onboarding/web/actions/login_enter_password |
| 5+ | login_acid, login_enter_two_factor, login_enter_alternate_identifier as challenged |
Steps 1-3 are verified end-to-end headless: guest activation, landing, and begin_login all succeed over pure HTTP, and X accepts the in-process Castle token (a live begin_login returns real business-logic responses through it, e.g. "We couldn't find an active X account with that username" for a bad handle, versus the "Something went wrong" you get with no token). Step 4 and the step 5+ challenge actions (login_acid, login_enter_two_factor, login_enter_alternate_identifier) are implemented against the SDUI field names; their happy-path field ids are inferred from the SDUI strings and classic-flow naming. The decoder surfaces the real actions/<id> and field names on every response, and session_token is threaded through challenge screens, so an unhandled step throws with the decoded actions attached for a quick fix.
Rate limiting
X throttles begin_login aggressively per account and per IP (a flagged account returns "temporarily limited your login." for a while, even from a fresh IP). Space out attempts and rotate IPs when running at any volume.
Headers
Every jfapi call carries the same set (the authenticated client.jf() uses these too):
authorization: Bearer <web bearer>
x-guest-token: <guest token>
x-client-transaction-id: <per-request, path-bound>
x-jf-v: JP-5
x-jf-client-theme: light | dark | business
x-twitter-active-user: yes
timezone: <IANA tz>
accept-language: enOnce a ct0 cookie exists it is echoed as x-csrf-token. Responses are the Jetfuel binary wire format (arraybuffer); decode with jetfuel.decode to read the next action id (onboarding/web/actions/<id>), the form field names, and any errors strings.
Action bodies
actions/ POSTs are application/x-www-form-urlencoded. The client collects the form's named fields, then appends a Castle device token:
begin_login: username_or_email=<handle>&$castle_token=<token>
login_enter_password: password=<password>&$castle_token=<token>The identifier field validates against ^[a-zA-Z0-9@._+\-]+$. A challenge_response field exists on the identifier form but is only populated for WebAuthn/passkey (remotes/passkey_one_fa) flows, not password login.
The Castle gate
Every actions/ POST is gated on $castle_token, a Castle device-fingerprint token (~4 KB, <pk_prefix>|<base64 payload>). It is minted client-side by Castle's SDK, dynamically imported and gated behind the responsive_web_castle_sdk_enabled feature switch with the public key in responsive_web_castle_public_key.
Without a valid token, begin_login returns 200 with a body that decodes to just "Something went wrong. Please try again.". With one, the flow advances. The token is a Castle device attestation, format <id>|<base64 payload> (~4.5 KB). X's own bundle mints it via __webpack_require__("835784").ru(), which lazy-loads the Castle SDK chunk (ondemand.castle.<hash>.js) and calls its createRequestToken().
emusks mints it in pure Node, no browser (src/castle.js): it runs Castle's own SDK inside a node:vm sandbox with a mocked browser env (linkedom document + a synthetic navigator/screen/performance), captures the webpack module (84197), calls configure({ pk }) with X's Castle public key, and returns createRequestToken(). createRequestToken is entirely client-side (no network, no WebCrypto: it custom-encodes navigator/performance signals), so it runs headlessly. First mint ~150 ms, subsequent ~50 ms (config cached). Verified: X accepts the Node-minted token and begin_login advances past the gate.
import { mintCastleToken } from "emusks/src/castle.js";
const token = await mintCastleToken(); // "nAKJehaX|JABXNS9LK2xY..."getCastleToken is called once per actions/ POST; the token is not action-specific, so any fresh one works. Two values can rotate when X redeploys and must be refreshed then: the pk (DEFAULT_CASTLE_PK) and the bundled SDK (src/vendor/castle-sdk.min.js). Both are overridable via createCastleMinter({ pk, sdkSource }); re-extract the pk from the login page's inline feature config (responsive_web_castle_public_key) and the SDK from the ondemand.castle.*.js chunk URL.
New-device logins hit a verify_code / knowledge_check screen (and email logins add a "confirm username / Use password" step carrying a session_token, which the flow passes through); email codes come via onRequest("email_code"). Login is rate-limited per account and per IP; hammering begin_login returns "temporarily limited your login.", surfaced as a clean rate-limit error.