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 storagecheck 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:
- Auto-selects a free loopback port from the registered range
(
https://localhost:3764/…:3769/) and prints the chosen callback. - Prints (and tries to open) the authorize URL with the scopes keryx needs,
including
instagram_business_content_publish. - Captures the returned
codefrom whichever comes first: - 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. - a code pasted on stdin — the fallback for a box the browser can't reach. Both paths run together; first to arrive wins.
- Exchanges
code → short-lived → long-lived(~60-day) token. - 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 acodeyou copied from the browser's address bar directly. Pair with--redirectset to the same URI the code was issued for.
Prerequisites¶
platforms.instagram.app_idset (the Instagram app ID).INSTAGRAM_APP_SECRETin 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-
httpsredirect, regardless of host, so register thehttpsrangehttps://localhost:3764/…https://localhost:3769/. keryx picks the first free one; the range must stay in sync withloopbackPortRangein 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:
- Open the authorize URL, approve, and let the browser land on
https://localhost:<port>/?code=…#_(it may show connection refused — thecodeis still in the address bar). - Copy the
code(betweencode=and#_) and runkeryx 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.
- Auto-selects a free ephemeral
http://127.0.0.1:<port>/callback (or use--redirectto pin one). - Opens (or prints) the authorize URL with
access_type=offline+ consent prompt + PKCE so Google returns a refresh token. - Captures the
code(loopback or stdin paste), exchanges it (x/oauth2), and stores the refresh token: keychain on a desktop, else the config file. Writesplatforms.youtube.enabled(+channel_idwhen readable). Never prints the token.
Prerequisites¶
platforms.youtube.client_idset — the Google OAuth Desktop app client id.YOUTUBE_CLIENT_SECRETin 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.)
- Auto-selects a free
http://127.0.0.1:<port>/callback/redirect (or use--redirectto pin one) and prints/opens the authorize URL with PKCE — note TikTok's quirks: the params useclient_key(notclient_id) and the PKCEcode_challengeis hex-encoded SHA256 (not base64url), methodS256. - Captures the
code(loopback or stdin paste) — no hosted page needed. - 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_keyset — the TikTok app client key.TIKTOK_CLIENT_SECRETin the environment — the client secret.- A Desktop loopback redirect registered (e.g.
http://127.0.0.1:*/callback/, wildcard port), scopesuser.info.basic+video.publish, and Direct Post enabled. Posts areSELF_ONLYuntil 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).
- 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).--redirectoverrides it. - 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. Writesplatforms.linkedin.{author_urn, enabled, redirect_uri}. Never prints the token.
Prerequisites¶
platforms.linkedin.client_idset;LINKEDIN_CLIENT_SECRETin 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).