Playbook: LinkedIn Credential Rotation

Covers: LINKEDIN_ACCESS_TOKEN · LINKEDIN_CLIENT_ID · LINKEDIN_CLIENT_SECRET


Overview

LinkedIn OAuth credentials for personal brand automation (content publishing via n8n on bms-4).

KeySOPS file(s)Purpose
LINKEDIN_ACCESS_TOKENn8n-bms4Short-lived OAuth access token (~60 day lifetime)
LINKEDIN_CLIENT_IDn8n-bms4 + brandpilotOAuth app Client ID (stable; changes only when app is recreated)
LINKEDIN_CLIENT_SECRETn8n-bms4 + brandpilotOAuth app Client Secret
LINKEDIN_PERSON_URNn8n-bms4Author URN used as the author field on published posts
LINKEDIN_TOKEN_EXPIRESn8n-bms4Cached expiry of the current access token

⚠️ brandpilot.env.sops holds the SAME OAuth app credentials as n8n-bms4.env.sops. Verified 2026-07-19 by SHA-256 comparison: LINKEDIN_CLIENT_ID and LINKEDIN_CLIENT_SECRET are byte-identical across both files. Rotating the Client Secret in n8n-bms4 alone will silently break BrandPilot’s LinkedIn publishing. Always update both files in the same PR.

Tier: ❌ Tier 3 Manual — OAuth 2.0 authorization requires browser login to linkedin.com.

When to rotate

  • LINKEDIN_ACCESS_TOKEN: Every ~60 days — LinkedIn enforces this expiry. Rotate when n8n publishing workflows fail with 401 Unauthorized.
  • LINKEDIN_CLIENT_SECRET: On exposure only.
  • LINKEDIN_CLIENT_ID: Only if the OAuth application is recreated.

Pre-rotation checklist

  • No LinkedIn publishing jobs running in n8n (wait for current job to finish)
  • Browser available for OAuth consent flow

Rotation steps

Access token refresh (most common — every 60 days)

1. Complete OAuth flow (human required)

Option A — via n8n UI (preferred):

  1. n8n UI (https://n8n.srv1072950.hstgr.cloud) → Credentials → find LinkedIn OAuth2 credential
  2. Click Reconnect → authorize in the browser OAuth consent page → n8n stores the new token automatically

Option B — manual flow:

  1. https://www.linkedin.com/developers/apps → select the p24-infra app → Auth tab
  2. Use the displayed OAuth 2.0 Authorization URL to initiate the consent flow in a browser
  3. After authorization, extract access_token from the callback URL or token response
  4. Store in .env.local temporarily — never paste in chat

2. Distribute (Claude handles)

If updated via n8n UI (Option A): only SOPS backup needs updating. Update secrets/n8n-bms4.env.sops — key: LINKEDIN_ACCESS_TOKEN.

$env:SOPS_AGE_KEY_FILE = "C:\Users\konar\.age\p24-infra-keys.txt"
# Edit LINKEDIN_ACCESS_TOKEN per sops-edit-operations.md
sops --decrypt --input-type dotenv --output-type dotenv secrets\n8n-bms4.env.sops | Out-Null
if ($LASTEXITCODE -ne 0) { throw "SOPS corrupt — do NOT commit" }

3. Verify

Trigger the LinkedIn publishing n8n workflow manually with a draft/test post. Confirm 201 Created or 200 OK response from the LinkedIn API.

Non-destructive check of the token currently in SOPS — reads the real expiry straight from LinkedIn, so you can confirm whether a rotation is actually needed before doing one:

export SOPS_AGE_KEY_FILE=/home/claude-runner/.age/p24-infra-keys.txt
_li() { sops -d --input-type dotenv --output-type dotenv secrets/n8n-bms4.env.sops \
  | grep "^$1=" | cut -d= -f2- | tr -d '"'; }
TOK=$(_li LINKEDIN_ACCESS_TOKEN)
 
# Liveness — 200 means the token still works
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $TOK" \
  https://api.linkedin.com/v2/userinfo
 
# Real expiry — never prints the token itself
curl -s -X POST https://www.linkedin.com/oauth/v2/introspectToken \
  --data-urlencode "token=$TOK" \
  --data-urlencode "client_id=$(_li LINKEDIN_CLIENT_ID)" \
  --data-urlencode "client_secret=$(_li LINKEDIN_CLIENT_SECRET)" \
  | python3 -c 'import json,sys,datetime as dt; d=json.load(sys.stdin); \
print(d.get("status"), dt.datetime.fromtimestamp(d["expires_at"], dt.timezone.utc).date())'
 
unset TOK

4. Update the rotation registry — MANDATORY

Do not skip this step. credential-exporter has no way to see the real token expiry; it computes overdue-ness as last_rotated + rotation_freq from dev_r_services. If you rotate the token and leave the registry stale, CredentialRotationOverdue fires every few hours against a perfectly healthy credential and each firing opens a fresh GitHub issue.

This is not hypothetical — it happened. A rotation on 2026-07-08 was not recorded, and the alert re-fired 13 times over the following 11 days (#3447 … #4302) while the live token was valid until 2026-09-06. See #4302 for the full trace.

Set last_rotated to the token’s real created_at and next_due to its real expires_at (both from the introspection output above):

curl -s -X PATCH \
  "${SUPABASE_URL}/rest/v1/dev_r_services?service_name=eq.LINKEDIN_OAUTH&element_type=eq.credential" \
  -H "apikey: ${SUPABASE_SERVICE_KEY}" \
  -H "Authorization: Bearer ${SUPABASE_SERVICE_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"last_rotated":"YYYY-MM-DD","next_due":"YYYY-MM-DD"}'

Then confirm p24_credential_days_overdue{credential="LINKEDIN_OAUTH"} returns to 0 on the next credential-exporter scrape.


Client Secret rotation (on exposure)

1. Rotate in LinkedIn Developer Portal (human required)

  1. https://www.linkedin.com/developers/apps → select the app → Auth tab
  2. Client SecretRegenerate → copy new secret immediately
  3. Note: Client ID cannot be changed. If Client ID is also compromised, create a new app.

2. Distribute (Claude handles)

Update both secrets/n8n-bms4.env.sops and secrets/brandpilot.env.sops — key: LINKEDIN_CLIENT_SECRET (they share one OAuth app — see the warning in §Overview). Also update the n8n LinkedIn OAuth2 credential in the n8n UI with the new Client Secret.

Important: after changing the Client Secret, all existing access tokens become invalid. The OAuth flow must be re-completed (see Access Token Refresh above).


Log rotation

| YYYY-MM-DD HH:MM UTC | #ISSUE | LINKEDIN_ACCESS_TOKEN | scheduled 60d | radieu | n8n-bms4.env.sops + n8n vault |

Recovery

  • OAuth flow fails (rate limit): wait 15 minutes and retry. Old token still valid until ~60d expiry.
  • n8n credential reconnect fails: manually enter the access token via n8n Credentials UI.
  • Token expired before rotation: complete the OAuth flow immediately; no rollback possible.

References

  • secret-rotation-access-matrix.md — Tier 3 classification
  • docs/playbooks/sops-edit-operations.md — SOPS write pattern
  • #1525 — enable offline_access scope so the token can auto-refresh (durable fix; would retire the 60-day manual OAuth flow entirely)