Castle and the login flow
Twitter moved 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. emusks drives that flow over plain HTTP with cycletls. The one piece it can't do in Node is the Castle device token, so by default it mints that in a real headful Chrome.
For how to call login(), see authentication.
Flow and Castle options
flow | Transport | Status |
|---|---|---|
"jetfuel" (default) | jfapi over cycletls, Castle token from a real browser | works |
"classic" | the old onboarding/task.json flow | Twitter rejects it, kept for reference |
The classic flow 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.
| Castle source | How the token is minted | Notes |
|---|---|---|
castle: "browser" (default) | headful Chrome over CDP, one browser per token | needs Chrome or Chromium; a window flashes briefly per token |
castle: "node" | the vendored Castle SDK in a node:vm sandbox | no browser, but Twitter currently rejects these tokens with "temporarily limited" |
getCastleToken(action) | your own function | replaces both, for example with a browser you manage |
castleProfile | the Node minter, fed with a saved browser profile | a path or object; also read from EMUSKS_CASTLE_PROFILE or .emusks/castle-profile.json when present. false turns it off. |
await client.login({ type: "password", username, password });
import { createBrowserCastleMinter } from "emusks";
const minter = await createBrowserCastleMinter();
await client.login({ type: "password", username, password, getCastleToken: () => minter.mint() });
minter.close();castleHeadless: true mints in headless Chrome instead, which Twitter usually rejects. Set EMUSKS_CHROME_PATH if Chrome isn't in a standard location.
The wire flow
Every call runs on a guest token until auth_token appears in the cookie jar.
| Step | Request |
|---|---|
| 1. Guest token | POST https://api.x.com/1.1/guest/activate.json with the web bearer |
| 2. Landing | GET https://x.com/i/jfapi/onboarding/web?mode=login, a Jetfuel page with the form and the 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 and later | login_acid, login_enter_two_factor, login_enter_alternate_identifier, when challenged |
Steps 1 to 3 were verified end to end over plain HTTP. With a Castle token, begin_login returns real answers, such as "We couldn't find an active Twitter account with that username" for a bad handle, where a missing token gets "Something went wrong". Step 4 and the challenge actions use field ids taken from the SDUI strings and the classic flow's naming. emusks decodes every response, threads session_token through challenge screens, and throws with the decoded actions attached when it meets a step it doesn't handle.
Rate limiting
Twitter throttles begin_login per account and per IP. A flagged account gets "temporarily limited your login." for a while, even from a new IP, and emusks surfaces that as a rate-limit error. Space out attempts and rotate IPs when logging in many accounts.
Headers
Every jfapi call carries these, and the logged-in client.jf() sends the same set:
authorization: Bearer <web bearer>
x-guest-token: <guest token>
x-client-transaction-id: <per request, bound to the path>
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 use the Jetfuel binary format. Decode them 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 and appends a Castle 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@._+\-]+$. The identifier form also has a challenge_response field, which only WebAuthn and passkey flows (remotes/passkey_one_fa) fill in.
Challenges
New-device logins hit a verify_code or knowledge_check screen. Email logins add a "confirm username / Use password" step that carries a session_token, which the flow passes along. That step calls onRequest("alternate_identifier", { challengeType, accountFound, accountFoundType }), where challengeType is Username, Email or Phone. Returning nothing takes the "Use password" bypass. Email codes come through onRequest("email_code").
With type: "email_code", emusks declines the password at the password-or-code fork and follows the code path. If Twitter insists on a password, it throws email-code login is not available for this account.
The Castle gate
Every actions/ POST needs $castle_token, a Castle device-fingerprint token of about 4 KB in the form <pk_prefix>|<base64 payload>. Twitter's web app lazy-imports the Castle SDK as an ES module (x-web/x-web/assets/castle.umd-<hash>.js, behind the responsive_web_castle_sdk_enabled feature switch, with the public key in responsive_web_castle_public_key) and calls configure({ pk }) and then createRequestToken().
Without a token, begin_login returns 200 with a body that decodes to "Something went wrong. Please try again." With a token Twitter trusts, the flow moves on.
Why a browser
The obvious approach is to run Castle's SDK in Node, and emusks can (castle: "node", src/castle.js). It runs the SDK in a node:vm sandbox with a fake browser environment (a linkedom document plus synthetic navigator, screen and performance) and returns createRequestToken(). The token is well formed, and for a handle that doesn't exist begin_login even answers normally. For a real account, though, Twitter soft-rejects it with "We've temporarily limited your login." The token is bound to the browser that minted it: canvas, WebGL and audio signals, the x.com origin, and the m.castle.io device handshake. The node:vm signals don't pass, and neither do headless Chrome or stealth Firefox (camoufox). Only tokens minted by a real, non-headless Chrome on the x.com origin survive being sent from cycletls.
So the default mints in a real headful Chrome over CDP (src/castle-browser.js). It launches Chrome, opens x.com/i/flow/login, resolves the current Castle SDK chunk from Twitter's build (the login page, then the x-web entry, then external-referer, then castle.umd), imports it and calls configure({ pk }) and createRequestToken().
- Chrome or Chromium must be installed. Set
EMUSKS_CHROME_PATH, or passchromePathtocreateBrowserCastleMinter. - The window is real and visible for a moment. emusks places it off-screen, which macOS clamps, and minimizing it throttles the renderer and breaks the mint.
- By default each token gets a new browser (
browserCastleToken), so nothing lingers between actions. To mint many tokens, keep one minter open:
import { createBrowserCastleMinter } from "emusks";
const minter = await createBrowserCastleMinter(); // { sdkUrl, mint, close }
const token = await minter.mint(); // "e8bl5yQW|JAB..."
minter.close();createBrowserCastleMinter takes { headless, chromePath, pk, proxy }.
getCastleToken is called once per actions/ POST. The token isn't tied to the action, so any fresh one works.
If you refresh the vendored Node SDK, two values change when Twitter redeploys: the public key (DEFAULT_CASTLE_PK) and the SDK itself (src/vendor/castle-sdk.min.js). createCastleMinter({ pk, sdkSource }) overrides both. The bundled SDK is currently stale and mints an old build id that Twitter rejects. The browser path always loads Twitter's current chunk.