Skip to content

keryx auth

Run a platform's interactive OAuth flow and store its access token, so posting "just works" without hand-cranking the API. Instagram is the first platform.

keryx auth <platform>                          # instagram | youtube | tiktok | linkedin | bluesky | facebook | threads | x | mastodon
keryx auth instagram --redirect <uri>          # pin a specific registered redirect
keryx auth instagram --redirect <uri> --code <code>   # exchange a pasted code (non-interactive)

Where the token is stored

Every platform stores its credential the same way: the OS keychain when a desktop secret service is available, otherwise the config file.

On a headless box or in CI there is no keychain, so the config file is the normal path rather than an edge case — and keryx names the exact file it wrote to:

✓ Authorised Instagram (user_id 12345).
  Token stored in /home/you/.keryx/config.yaml (PLAINTEXT — do not commit this file); valid ~60 days.

Credentials belong in user config, not a project's .keryx.yaml. A project config is committed, so a token in it is a token in your git history. What a project should carry is the wiring — app_id, user_id, client_id, open_id, channel_id, author_urn — which says which account it posts to without saying how to authenticate as it.

doctor checks the project file for you

keryx doctor reads the .keryx.yaml in the current directory and reports any credential-shaped key carrying a value:

$ keryx doctor
  [!!] Project credentials: 3 literal credentials in .keryx.yaml
       Key(s): platforms.instagram.access_token, platforms.tiktok.refresh_token,
       platforms.youtube.refresh_token. They are in /home/you/blog/.keryx.yaml, which is
       committed to the repository — move them to your user config (`keryx auth <platform>`
       writes there) or supply them from the environment.

It reports key names only, never values — doctor output gets pasted into issues and support threads. It matches on the shape of a key rather than a fixed list, so a platform keryx does not know about yet, or a key you added by hand, is still caught. No project file in the current directory is reported as a skip, not a pass: nothing was examined, so nothing is being vouched for.

There is a separate, GTB-provided Credential storage check in the same report. It covers GTB's own credential keys (Anthropic, GitHub, GitLab, …) and knows nothing about keryx's platform tokens, so it can read [OK] while this check reports findings. They are looking at different things.

Instagram

keryx auth instagram runs the Instagram Login OAuth flow — no --redirect needed in the common case:

  1. Auto-selects a free loopback port from the registered range (https://localhost:3764/ … :3769/) and prints the chosen callback.
  2. Prints (and tries to open) the authorize URL with the scopes keryx needs, including instagram_business_content_publish.
  3. Captures the returned code from whichever comes first:
  4. a loopback callback server bound to all interfaces on the chosen port — so the browser reaches it via localhost, an SSH port-forward, or the host's LAN IP. It auto-captures the code (nothing to copy). Meta requires an https redirect, so the server serves TLS with an in-memory self-signed cert; the browser warns once that the cert is untrusted — proceeding is safe (a local-only, throwaway cert). See Certificate trust.
  5. a code pasted on stdin — the fallback for a box the browser can't reach. Both paths run together; first to arrive wins.
  6. Exchanges code → short-lived → long-lived (~60-day) token.
  7. Stores the token: the OS keychain on a desktop, else the config file (a headless dev server / CI has no keychain). Writes platforms.instagram.user_id, redirect_uri + enabled. The token is never printed.

Flags

  • --redirect <uri> — pin a specific OAuth redirect URI instead of auto-selecting. Must be one of the URIs registered in the Meta app. Use this to force a port, or for a non-loopback redirect.
  • --code <code> — skip the interactive step and exchange a code you copied from the browser's address bar directly. Pair with --redirect set to the same URI the code was issued for.

Prerequisites

  • platforms.instagram.app_id set (the Instagram app ID).
  • INSTAGRAM_APP_SECRET in the environment (the Instagram app secret).
  • The loopback redirect URIs registered in the Meta app (API setup with Instagram login → Business login settings). Meta rejects every non-https redirect, regardless of host, so register the https range https://localhost:3764/ … https://localhost:3769/. keryx picks the first free one; the range must stay in sync with loopbackPortRange in the code.

Typical run

keryx auth instagram
# → Using OAuth callback https://localhost:3764/
# → opens the authorize URL; approve consent (lists content publishing)
# → browser lands on https://localhost:3764/?code=… ; click through the
#   one-time cert warning; keryx captures + stores the token automatically.

On a remote box, port-forward first (ssh -L 3764:localhost:3764 …) or just hit the host's LAN IP — the callback binds all interfaces.

Manual fallback (--code)

If the browser can't reach the callback at all, keryx also prints the URL and waits for a pasted code:

  1. Open the authorize URL, approve, and let the browser land on https://localhost:<port>/?code=…#_ (it may show connection refused — the code is still in the address bar).
  2. Copy the code (between code= and #_) and run keryx auth instagram --redirect https://localhost:<port>/ --code <code>, using the same port the authorize URL used.

The code is single-use and expires in minutes — grab it and exchange promptly.

Certificate trust

Today the callback presents an in-memory self-signed cert, so the browser shows a one-time "untrusted certificate" warning — safe to click through, since the cert is local-only and regenerated each run. A future (pre-v1) enhancement will add a keryx init hook to install a phpboyscout-signed trusted root CA (opt-in), so the callback cert is trusted and the warning disappears.

See the how-to: Get Instagram posting credentials.

Token resolution

The Instagram publisher reads its token in this order: INSTAGRAM_ACCESS_TOKEN env (CI / manual override) → OS keychain → config file. auth refresh (rotate before the ~60-day expiry, alert on failure) is the next piece.

YouTube

keryx auth youtube runs Google's OAuth flow and stores a refresh token (the durable secret; access tokens are minted from it on demand). It reuses the same capture machinery (pkg/oauth) as Instagram, but Google is RFC 8252-compliant, so the callback is a plain-http loopback on any free port — no cert, no warning, and nothing to pre-register.

  1. Auto-selects a free ephemeral http://127.0.0.1:<port>/ callback (or use --redirect to pin one).
  2. Opens (or prints) the authorize URL with access_type=offline + consent prompt + PKCE so Google returns a refresh token.
  3. Captures the code (loopback or stdin paste), exchanges it (x/oauth2), and stores the refresh token: keychain on a desktop, else the config file. Writes platforms.youtube.enabled (+ channel_id when readable). Never prints the token.

Prerequisites

  • platforms.youtube.client_id set — the Google OAuth Desktop app client id.
  • YOUTUBE_CLIENT_SECRET in the environment — the client secret.
  • The OAuth app published to "In production" (else refresh tokens expire in 7 days). Uploads stay private until the app passes Google's audit. See the how-to: Get YouTube posting credentials.

Token resolution

The YouTube publisher reads its refresh token YOUTUBE_REFRESH_TOKEN env → OS keychain → config file, and mints access tokens from it via x/oauth2 (YouTube's refresh token doesn't rotate, so refresh = keep it used).

TikTok

keryx auth tiktok runs TikTok's OAuth flow and stores a refresh token (the durable secret). As a Desktop app, keryx uses a loopback redirect, so it auto-captures the code on a free http://127.0.0.1:<port>/ callback — the same flow as YouTube — with stdin paste as the headless fallback. (Only TikTok Web apps are barred from loopback; Desktop apps require it.)

  1. Auto-selects a free http://127.0.0.1:<port>/callback/ redirect (or use --redirect to pin one) and prints/opens the authorize URL with PKCE — note TikTok's quirks: the params use client_key (not client_id) and the PKCE code_challenge is hex-encoded SHA256 (not base64url), method S256.
  2. Captures the code (loopback or stdin paste) — no hosted page needed.
  3. Exchanges it at TikTok's bespoke token endpoint and stores the refresh token: keychain on a desktop, else the config file. Writes platforms.tiktok.{open_id, enabled}. Never prints the token.

Prerequisites

  • platforms.tiktok.client_key set — the TikTok app client key.
  • TIKTOK_CLIENT_SECRET in the environment — the client secret.
  • A Desktop loopback redirect registered (e.g. http://127.0.0.1:*/callback/, wildcard port), scopes user.info.basic + video.publish, and Direct Post enabled. Posts are SELF_ONLY until the app passes TikTok's audit. See the how-to: Get TikTok posting credentials.

Token resolution — the rotating refresh token

The TikTok publisher reads its refresh token TIKTOK_REFRESH_TOKEN env → OS keychain → config file. TikTok access tokens last ~24h, so every post mints a fresh one from the refresh token — and TikTok may rotate the refresh token on that call, so keryx persists whatever comes back immediately (a dropped rotation locks the account out). This per-post mint-and-persist is the concrete shape auth refresh generalises across platforms.

LinkedIn

keryx auth linkedin runs LinkedIn's OAuth flow and stores the access token (~60 days). LinkedIn is a standard OAuth2 provider (client_id/client_secret), so it reuses the loopback capture (plain-http, like YouTube) and x/oauth2. It also reads the member's Person URN from /v2/userinfo (the post author).

  1. Picks the first free redirect in http://127.0.0.1:3764/ … :3769/, the block every platform shares (register them on the app; LinkedIn exact-matches). --redirect overrides it.
  2. Approve the permissions; keryx captures the code (loopback or paste), exchanges it, reads the Person URN, and stores the access token (keychain, else config) + its expiry. Writes platforms.linkedin.{author_urn, enabled, redirect_uri}. Never prints the token.

Prerequisites

  • platforms.linkedin.client_id set; LINKEDIN_CLIENT_SECRET in the environment.
  • App on a verified Company Page with "Share on LinkedIn" + "Sign In with LinkedIn using OpenID Connect" products; scopes w_member_social + openid + profile. See the how-to: Get LinkedIn posting credentials.

Token resolution — the no-refresh case

The LinkedIn publisher reads its access token LINKEDIN_ACCESS_TOKEN env → OS keychain → config file, alongside a stored expiry. A standard app gets no refresh token (refresh is Marketing-Developer-Platform-only), so when the ~60-day token expires keryx cannot auto-refresh — it fails with a clear "re-run keryx auth linkedin" error, and auth refresh alerts before expiry (R-AUTH-4) rather than rotating. (If an MDP app ever returns a refresh_token, keryx stores and uses it automatically.) This is the fourth OAuth token shape auth refresh handles; Bluesky's, below, is not OAuth at all.

Facebook

The same Meta app as Instagram, a different login (spec 0060 D10): keryx auth facebook runs Facebook Login for Business through the same https loopback capture, swaps the code for a user token and that for a long-lived one, then reads the Pages the account can post to (/me/accounts) and stores the chosen Page's own token (keychain, else the accounts file). The app id is platforms.facebook.app_id, the Meta app id from the dashboard header, and the secret FACEBOOK_APP_SECRET, the app's App secret; neither is the Instagram-login pair, which is a different id and secret on the same app. The dialog asks for five Page scopes, pages_read_user_content among them (a dependency of pages_manage_engagement); each must be available to the app or the dialog refuses the request as Invalid Scopes, naming the missing one. Writes platforms.facebook.{page_id,page_name,enabled,redirect_uri}. See the how-to: Get Facebook Page posting credentials.

Token resolution — the no-expiry case

The publisher reads the Page token FACEBOOK_PAGE_TOKEN env → OS keychain → accounts file. A long-lived Page token has no expiry; auth refresh reads the Page with it and reports healthy, or reauth-required on an OAuthException.

Threads

The Threads use case on the same Meta app (spec 0060 D15), with an app id and secret of its own (platforms.threads.app_id, THREADS_APP_SECRET): the https loopback capture on https://keryx.localhost:<port>/ rather than localhost, which Threads refuses at token exchange (a .localhost name is loopback in every browser; platforms.threads.redirect_host changes it), the code swapped for a short-lived token, that for a 60-day one, stored with its expiry and platforms.threads.{user_id,enabled, redirect_uri}. See the how-to: Get Threads posting credentials.

Token resolution — the refresh-in-place case

The publisher reads THREADS_ACCESS_TOKEN env → OS keychain → accounts file. auth refresh exchanges the token for a fresh 60-day one once it is within 20 days of expiry (Threads refuses to refresh a token under 24 hours old) and records the new expiry, the same shape as Instagram's.

X

Standard OAuth 2.0 with PKCE (spec 0060 D16): platforms.x.client_id and X_CLIENT_SECRET from the App's user authentication settings (Read and write, Web App), the https loopback capture, the code swapped for an access token and a refresh token, which is stored. See the how-to: Get X posting credentials.

Token resolution — the rotating case

The publisher reads the refresh token X_REFRESH_TOKEN env → OS keychain → accounts file, mints a two-hour access token from it per run, and persists the rotated refresh token that comes back, exactly TikTok's shape. auth refresh does the same on a schedule; a rejected token is reauth-required.

Mastodon

Any instance (spec 0060 D17). The first run asks for the instance and records it as platforms.mastodon.instance; keryx then registers itself as an app on it by API (client id in config, client secret with the credentials), runs the authorisation-code flow on the plain-http loopback http://127.0.0.1:3764/ (or takes a pasted code), verifies the token and stores it. It also reads the instance's posting limits into platforms.mastodon.constraints.{text_cap, link_weight}, because every instance sets its own. See the how-to: Get Mastodon posting credentials.

Token resolution — the static case

The publisher reads MASTODON_ACCESS_TOKEN env → OS keychain → accounts file. The token does not expire, so auth refresh verifies it with the instance and reports healthy, or reauth-required when the instance rejects it.

Bluesky

No app, no browser, no OAuth (spec 0060 D9): keryx auth bluesky prompts for the account identifier (handle or email) and an app password made at https://bsky.app/settings/app-passwords, creates a session to prove the pair works, stores the password (keychain, else the accounts file), records platforms.bluesky.identifier beside it and sets platforms.bluesky.enabled. BLUESKY_APP_PASSWORD in the environment stands in for the prompt on a headless host and is what CI uses. A self-hosted PDS is platforms.bluesky.service (default https://bsky.social). See the how-to: Get Bluesky posting credentials.

Token resolution — the static case

The publisher reads the app password BLUESKY_APP_PASSWORD env → OS keychain → accounts file, and creates a session per post. Nothing expires and nothing is written back; auth refresh reports it healthy after a session, or reauth-required when Bluesky rejects the password (revoked).