Skip to content
Social SDK Cloud, the hosted SDK, is taking early-access signups.Join the list
Social SDK megaphone markSocial SDK
Esc
↑↓navigate↵open⌘Jpreview
On this page

Connect accounts

Separate application credentials, user grants, callbacks, token storage, reconnects, and tenant authorization.

Authentication has two layers. The platform or managed provider authenticates the connection. Your application authenticates the user and decides which tenant may use that connection.

Keep credentials server-side

Platform app secrets, OAuth client secrets, access and refresh tokens, provider API keys, and webhook secrets belong in a server-side secret store. A browser can request a connection or operation through your application, but it must not receive unrestricted credentials or native clients.

Account lifecycle

Treat start, callback, successful connection, reconnect, unlink, disconnect, and upstream revocation as separate states. Store the backend instance, platform account identifier, tenant membership, scopes, token revision, and reconnect status in application-owned records. A display handle is not a stable authorization key.

Adapters report permission and reconnect failures as structured errors and outcomes. Everything after that is your application’s action:

  • Unlink removes one tenant’s grant. Delete the grant row; other tenants and the stored token are unaffected.
  • Disconnect removes your stored credential. The provider grant still exists, and the user can reconnect without new consent on most providers.
  • Revoke invalidates the token at the provider. Supply a provider revocation hook to CredentialManager or the Web connect handlers; the SDK does not choose a revocation endpoint for you. Google, X, and TikTok document revocation endpoints: Google, X, and TikTok. For other providers, check the provider’s own documentation; some only let the user remove the app from their account settings.

See tenant authorization and the account ownership diagram for the request boundary.

Discover accounts before selection

Use ConnectionManager from @opencoredev/social-sdk/server with a ConnectionStore and the platform’s OAuth provider factory. Call begin with the authenticated tenant and principal, requested platforms, and an exact redirect allowlist. Keep the returned attempt ID in your server session.

In the callback handler, pass the original callback URL and returned state to discover. The manager checks the authenticated principal, redirect, state, and expiry before claiming the code exchange. It saves the provider’s account references before returning them for your account picker. Browser-supplied account identifiers do not create grants.

After the user chooses accounts, call select with the attempt ID, authenticated tenant and principal, and selectedAccountIds. Selection uses the saved discovery result and consumes the attempt together with its grants in one store transaction. complete combines discovery and selection for applications that already know their selected accounts.

The integration skill includes an executable handler recipe. Its provider and storage are injected by the application.

Store contract and recovery

A production ConnectionStore must persist attempts and discovered accounts, atomically claim discovery, and atomically consume an attempt with all selected grants. The exchange claim must survive process restarts and must not expire automatically. MemoryConnectionStore implements this contract for tests, but loses state when the process exits.

Repeated callbacks can read a saved discovery result without exchanging the code again. If a code exchange is still running, or the process lost its response before saving discovery, the manager reports reconnect_required. Let an in-flight exchange finish or start a fresh connection. Replaying a one-use authorization code cannot safely recover missing credentials.

Connection attempts expire after ten minutes by default. Account selection must finish before expiry. The PKCE verifier stays in the store, and begin never returns it. begin returns providerState unless the provider’s start result sets providerStateSecret: true, in which case that value also stays in the store. The Bluesky provider sets it because its provider state holds the DPoP private key. Keep them private in server storage, encrypt stored credentials separately, and use compare-and-set token revisions when refreshing.

Durable storage

The server entry point includes SQL and Redis adapters for connection attempts and grants, credentials, refresh locks, and publication idempotency. They accept injected drivers and do not install a database client or contact a service when imported. SQL values use parameters; the SQL dialect determines placeholder syntax. Run schema initialization explicitly before serving requests.

Access-token expiry does not delete a credential record: its refresh token may still be needed. Connection grants also survive the callback attempt’s expiry. Idempotency records do not expire automatically, because losing an unresolved claim would allow a retry to dispatch the same publication again. Set an application retention policy that preserves records needed for authorization and reconciliation.

The drivers persist plaintext unless you provide encryption. Connection records can contain PKCE verifiers and private provider state, so protect those records as well as credential rows. Keep SQL and Redis access restricted to your server, and use a storage namespace and credential key that isolate each tenant, principal, backend, platform, and account.

Use createEncryptedCredentialStore to encrypt credential values with AES-256-GCM before they reach a backing store. The application supplies encryption keys and key identifiers. Keep old keys available while records encrypted with them still exist; changing the active key affects subsequent writes. Encryption authenticates the storage key as well as the ciphertext, so moving an encrypted value to a different key fails authentication.

Encryption does not authorize a credential lookup. Check the authenticated tenant’s grant before resolving a token, even when the token store is encrypted.

For example, inject a Node SQLite database and an application key resolver. node:sqlite needs Node.js 22.13 or later, or --experimental-sqlite on Node.js 22.12. A better-sqlite3 database works with the same executor, and createPostgresExecutor takes a pg client.

import type { DatabaseSync } from "node:sqlite";
import {
  createEncryptedCredentialStore,
  createSqliteExecutor,
  createSqlStores,
  DurableCredentialStore,
  parseEncryptedCredentialEnvelope,
  type EncryptedCredentialStoreOptions,
} from "@opencoredev/social-sdk/server";

async function openAuthStores(
  database: DatabaseSync,
  currentKeyId: string,
  resolveKey: EncryptedCredentialStoreOptions["resolveKey"],
) {
  const stores = createSqlStores({
    executor: createSqliteExecutor(database),
    dialect: "sqlite",
  });
  await stores.driver.createSchema();
  const envelopes = new DurableCredentialStore({
    driver: stores.driver,
    codec: {
      encode: JSON.stringify,
      decode(text) {
        const value: unknown = JSON.parse(text);
        return parseEncryptedCredentialEnvelope(value);
      },
    },
  });
  const credentials = createEncryptedCredentialStore({
    store: envelopes,
    namespace: "my-app-credentials",
    currentKeyId,
    resolveKey,
  });
  return { ...stores, credentials };
}

The database and key resolver must survive application restarts. This example encrypts credential values; protect the connection records separately. createPostgresExecutor adapts a pg-compatible query client, and SqlExecutor accepts a custom wrapper for other SQL clients. createRedisStores accepts a RedisScriptRunner whose eval forwards the script, key array, and argument array to your Redis client, including Upstash. Each script uses one key, which avoids cross-slot operations on Redis Cluster. Use a persistence configuration and eviction policy that retain claims and grants.

Web OAuth routes

createOAuthConnectHandlers returns four handlers that each accept a Web Request and return a Web Response. Register your providers in its server-owned connections map, with one callback origin and path per connection.

  • begin: POST JSON { connection, loginHint? }. Returns an authorization URL and expiry, and remembers the attempt in a Secure, HttpOnly, SameSite=Lax cookie.
  • callback: GET at the registered redirect URI, with the provider’s code and state. Checks the authenticated principal and stored attempt before exchanging the code and returning discovered accounts.
  • select: POST JSON { accountIds }. Creates grants only for persisted discovered accounts, consumes the attempt, and activates the selected credentials.
  • disconnect: POST JSON { grantId }. Looks up an authorized grant through the application, calls its revocation hook, then removes the local grant and credential.

Supply authenticate to resolve your server session and authorizeMutation to validate CSRF and other mutation policy. Neither tenant nor principal comes from browser JSON. The application session cookie must survive the provider’s top-level redirect. The default attempt cookie holds one active flow per browser; supply correlation backed by your server session when you need different tracking. Behind a proxy, derive publicUrl from trusted deployment configuration.

Configure the provider’s credential sink to stage encrypted credentials under the attempt ID during discovery. credentials.activate receives the committed grants and must promote only those credentials, discard the rest, and arrange cleanup of abandoned staging records. Grant creation and credential activation are separate steps. An activation_failed response means grants were committed but activation needs application reconciliation or a new connection. The handlers do not replay the authorization code.

The grants.find, grants.revoke, and grants.remove hooks own disconnect policy. They must account for credentials shared by several grants so disconnecting one principal does not invalidate another principal’s access. A failed revocation keeps local state available for retry. Every response uses Cache-Control: no-store, and error bodies omit provider text and connection secrets.

Meta account callbacks

Use createMetaDeauthorizeHandler and createMetaDataDeletionHandler for Meta’s form-encoded signed_request callbacks. These requests authenticate with the app secret, independently of browser sessions. The verifier checks HMAC-SHA256 over the original encoded payload, requires the supported algorithm and a bounded issued_at, and returns an app-scoped userId and stable requestId.

Map the user to your own accounts on the server. Make onDeauthorize and onDataDeletion durable and idempotent by requestId before resolving them. The deletion hook returns { statusUrl, confirmationCode }; the route sends Meta’s { url, confirmation_code } response only after that hook succeeds. The HTTPS status page must distinguish accepted work from completed deletion. Deploying the routes and deleting application data remain the host application’s responsibility.

Direct OAuth providers

The server package includes direct OAuth factories for YouTube, X, TikTok, Threads, Instagram Login, LinkedIn, and Bluesky. Create them only in server code. Each factory accepts an injected fetch for deterministic tests, an optional credentialSink, and provider credentials. The redirectUri passed to the factory must match the exact callback URI registered with the provider and with ConnectionManager.begin. When one callback discovers several accounts, the sink’s save runs once per selected account in order. If a later save fails, the earlier ones stay stored, so make save an idempotent upsert and let the user retry the connection.

YouTube

Default scopes:

  • openid
  • https://www.googleapis.com/auth/userinfo.profile
  • https://www.googleapis.com/auth/youtube.readonly
  • https://www.googleapis.com/auth/youtube.upload

PKCE: the provider flow does not add PKCE.

Account discovery calls channels.list with mine=true, which does not accept youtube.upload alone. Keep youtube.readonly (or a broader YouTube scope) in any custom scopes list, or discovery fails. The defaults cannot read YouTube Analytics reports or edit videos and comments. Add https://www.googleapis.com/auth/yt-analytics.readonly for analytics reports and https://www.googleapis.com/auth/youtube.force-ssl for comment writes and moderation.

While your Google Cloud OAuth consent screen is in Testing with an external user type, Google expires refresh tokens after seven days. Publishing the consent screen removes that limit. This is separate from the YouTube API Services compliance audit, which governs quota and some uploads.

const provider = youtubeOAuth({ clientId, clientSecret, redirectUri, credentialSink });
const next = await refreshOAuthToken("youtube", { clientId, clientSecret }, current);

X

Default scopes: tweet.read, tweet.write, users.read, offline.access, and media.write. PKCE: S256.

Refresh tokens can rotate. Persist the returned replacement atomically, as shown in refresh and persist.

const provider = xOAuth({ clientId, clientSecret, redirectUri, credentialSink });
const next = await refreshOAuthToken("x", { clientId, clientSecret }, current);

TikTok

Default scopes: user.info.basic and video.publish, which cover direct posting only. PKCE: the web Login Kit flow does not add PKCE.

Add video.upload to send drafts to the creator’s inbox, video.list to read posted videos, and user.info.stats for follower and like counts. Scopes are sent comma-delimited. The user can approve a subset, so check the granted scopes on the stored token.

Login Kit for Web accepts only registered redirect URIs that are absolute https URLs with no query parameters and no fragment, under 512 characters, up to 10 per app. ConnectionManager also accepts local HTTP and query-bearing callbacks for other providers, so it will not catch a TikTok mismatch for you.

TikTok documents 2-hour access tokens and 180-day refresh tokens; read the actual lifetimes from expires_in and refresh_expires_in in each response. Every refresh returns a different refresh token, and TikTok requires you to use the new one.

const provider = tiktokOAuth({ clientId, clientSecret, redirectUri, credentialSink });
const next = await refreshOAuthToken("tiktok", { clientId, clientSecret }, current);
// Replace the stored token with next, including next.refreshToken.

Source: TikTok token management and Login Kit for Web, accessed 2026-10-05.

Threads

Default scopes: threads_basic and threads_content_publish. PKCE: none is fabricated.

When you pass clientSecret, the provider exchanges the callback’s short-lived token for a long-lived token (about 60 days) before it calls your credentialSink. You only need exchangeLongLivedOAuthToken for a short-lived token you obtained outside the provider.

Refresh a long-lived token that is at least 24 hours old and not yet expired. Refreshing returns a new access token; store it in place of the old one.

const provider = threadsOAuth({ clientId, clientSecret, redirectUri, credentialSink });

// Later, from your own scheduled job:
const next = await refreshOAuthToken("threads", { clientId, clientSecret }, stored);

Instagram Login

Default scopes: instagram_business_basic and instagram_business_content_publish. PKCE: none is fabricated.

This is the Instagram API with Instagram Login. clientId and clientSecret are the Instagram app ID and Instagram app secret from the Instagram product settings in your Meta app dashboard, not the Meta app ID and secret.

The code exchange at api.instagram.com returns a short-lived token. When you pass clientSecret, the provider exchanges it for a long-lived token (about 60 days) at graph.instagram.com before it calls your credentialSink. Use exchangeLongLivedOAuthToken only for a short-lived token you obtained outside the provider.

Refresh a long-lived token that is at least 24 hours old and not yet expired, and store the returned access token.

const provider = instagramOAuth({ clientId, clientSecret, redirectUri, credentialSink });

// Later, from your own scheduled job:
const next = await refreshOAuthToken("instagram", { clientId, clientSecret }, stored);

LinkedIn

Default scopes: openid, profile, and w_member_social. PKCE: none is fabricated.

LinkedIn returns a refresh token only to applications it has approved for programmatic refresh. When the token response includes one, the provider stores it with the access token, and refreshOAuthToken("linkedin", ...) uses it. The refresh token’s one-year lifetime does not reset when you refresh. Without a refresh token, or after it expires, ask the user to reconnect.

Set linkedinApiVersion explicitly as YYYYMM.

const provider = linkedinOAuth({
  clientId,
  clientSecret,
  redirectUri,
  credentialSink,
  linkedinApiVersion: "202609",
});

if (stored.refreshToken !== undefined) {
  const next = await refreshOAuthToken("linkedin", { clientId, clientSecret }, stored);
}

Source: LinkedIn programmatic refresh tokens.

Bluesky

Default scope: atproto transition:generic. PKCE: S256, sent in a pushed authorization request with DPoP-bound tokens.

transition:generic is broad. It grants the same access as a Bluesky app password: create, update, and delete any record type, upload blobs, and call most endpoints. It does not include direct messages. To use the adapter’s chat.bsky methods, also request transition:chat.bsky, which works only together with transition:generic. Request it only when your app sends or reads messages.

For narrower access, pass granular permissions in scope, for example atproto repo:app.bsky.feed.post?action=create blob:*/* for an app that only creates posts. Reads that the PDS proxies to the Bluesky App View also need matching rpc: permissions with an aud. See the AT Protocol permission spec.

The scope in your client metadata document must list every scope you might request, and each authorization request must be a subset of it. Build the document with blueskyOAuthClientMetadata({ scope, ... }) using the same value.

blueskyOAuth follows the AT Protocol OAuth profile instead of a fixed provider endpoint. Pass the handle, DID, or server URL the user typed as loginHint to begin. There is no client secret: clientId is the URL of your client metadata document, and confidential clients sign with an ES256 clientKey. The verified session goes to sessionSink rather than credentialSink.

The session holds the access token, a rotating refresh token, and dpopKey, the private key the tokens are bound to. Encrypt and store the whole session object, and replace it after every refresh.

const scope = "atproto transition:generic transition:chat.bsky";
const secureRequests = { assertEgressAllowed: assertPublicEgress, fetch: pinnedFetch };
const provider = blueskyOAuth({
  clientId,
  clientKey,
  redirectUri,
  scope,
  sessionSink,
  ...secureRequests,
});
await manager.begin({
  ...request,
  platforms: ["bluesky"],
  provider,
  loginHint: "alice.bsky.social",
});
const next = await refreshBlueskyOAuthSession(current, { clientId, clientKey, ...secureRequests });
// Store next in place of current, in one compare-and-set write.

Every Bluesky OAuth network request requires both assertEgressAllowed and an address-pinned fetch, including connection, refresh, and resource requests. assertPublicEgress and pinnedFetch above are application services: the policy must reject private resolutions, and the transport must connect only to approved addresses. See the Bluesky transport contract for the complete setup and client metadata.

Source: AT Protocol OAuth, accessed 2026-10-05.

The defaults are a starting point. Pass scopes for the operation you are connecting, such as a read-only Google scope list for an analytics dashboard or LinkedIn’s organization permission when your app has that product approval. Provider app review, account eligibility, callback registration, and quota remain application setup responsibilities.

Complete server flow

When a provider discovers more than one account, the default credential sink receives credentials for every validated account. Pass selectAccounts to an OAuth factory when credentials should be persisted for only a chosen subset. The selector receives the discovered accounts and attempt and must return distinct discovered account IDs. This selection controls credential persistence; ConnectionManager.select still controls which discovered accounts become tenant grants.

A complete server flow looks like this:

import {
  ConnectionManager,
  MemoryConnectionStore,
  type OAuthCredentialSink,
  youtubeOAuth,
} from "@opencoredev/social-sdk/server";

const store = new MemoryConnectionStore(); // tests only; use durable storage in production
const credentialRows = new Map<string, unknown>();
const credentials: OAuthCredentialSink = {
  async save(input) {
    // Replace this map with encrypted, tenant-scoped persistence in production.
    const { tenantId, principalId, backend } = input.attempt;
    const { platform, accountId } = input.account.ref;
    const key = JSON.stringify([tenantId, principalId, backend, platform, accountId]);
    credentialRows.set(key, input.token);
  },
};
const provider = youtubeOAuth({
  clientId: process.env.GOOGLE_OAUTH_CLIENT_ID!,
  clientSecret: process.env.GOOGLE_OAUTH_CLIENT_SECRET!,
  redirectUri: "https://app.example.com/oauth/youtube/callback",
  // youtube.readonly is required: account discovery calls channels.list with mine=true.
  scopes: [
    "openid",
    "https://www.googleapis.com/auth/youtube.readonly",
    "https://www.googleapis.com/auth/youtube.upload",
  ],
  credentialSink: credentials,
});
const manager = new ConnectionManager({ store });

export async function handleConnection(input: {
  readonly tenantId: string;
  readonly principalId: string;
  readonly callbackUrl: string;
  readonly returnedState: string;
  readonly selectedAccountId: string;
}) {
  // Authenticated server request: return started.authorizationUrl to the browser.
  const started = await manager.begin({
    backend: "default", // the registry key the consuming client uses for this adapter
    tenantId: input.tenantId,
    principalId: input.principalId,
    platforms: ["youtube"],
    capabilities: ["posts.publish"],
    redirectUri: "https://app.example.com/oauth/youtube/callback",
    allowedRedirectUris: ["https://app.example.com/oauth/youtube/callback"],
    provider,
  });

  // Callback request: keep the complete callback URL and the state extracted from it.
  const discovered = await manager.discover({
    attemptId: started.attempt.id,
    tenantId: input.tenantId,
    principalId: input.principalId,
    callbackUrl: input.callbackUrl,
    returnedState: input.returnedState,
    allowedRedirectUris: ["https://app.example.com/oauth/youtube/callback"],
    provider,
  });
  // Show discovered account IDs to the authenticated user, then accept only their choice.
  const grants = await manager.select({
    attemptId: started.attempt.id,
    tenantId: input.tenantId,
    principalId: input.principalId,
    selectedAccountIds: [input.selectedAccountId],
  });
  return { started, discovered, grants };
}

The backend passed to begin becomes the backend field of every discovered account reference, and the client routes on it. Use the same key your client registers the adapter under: createSocial({ backend }) registers a single adapter as "default", and createSocial({ backends: { youtube } }) registers it as "youtube". A grant saved under any other key fails with Unknown backend instance.

MemoryConnectionStore and an in-memory credential sink are suitable for tests only. A production sink must encrypt tokens at rest, key them by tenant and principal as the example does, restrict reads to that tenant and principal, redact tokens from logs, and support atomic refresh-token rotation. The callback URL and returned state come from the provider redirect; do not accept an account ID or token from browser form data. The repository contains runnable TypeScript versions of this flow in examples/snippets/oauth-youtube.ts and examples/snippets/oauth-tiktok.ts. They use environment variables for client credentials and stop after returning the authorization URL until an application supplies a callback.

Refresh and persist

The refresh helpers make one request and return the new token set. They do not store anything and do not run on a schedule. Your code decides when to refresh and must replace the stored credential atomically, because a provider that rotates refresh tokens invalidates the old one. Two workers refreshing the same account at once can otherwise overwrite each other.

For expiry-aware resolution, construct CredentialManager with a refresh hook and call resolve(key). It refreshes when the stored access token is within refreshSkewMs of expiry, then returns the value and its current revision. get(key) remains a raw storage read. A missing expiry means the token does not expire; a malformed expiry is rejected. The hook runs even when the credential has no refresh token, because Threads and Instagram refresh with the access token. refreshOAuthToken raises reconnect_required for providers that need a refresh token and have none. Without a hook, an expiring credential requires reconnecting.

The manager re-reads freshness after acquiring the shared lock. Before calling the provider, it persists a refresh marker. A timeout or uncertain response blocks another automatic refresh of that credential, including after the lock lease expires. Reconcile or save a newly authorized credential to recover. The error thrown in that case is a new SocialError with a code and an after-reconnect retry disposition. It never wraps the provider or store error, because those can contain tokens. A definite rejection from the refresh hook, and any failure of the revoke hook, credential store, or lock, is also rethrown as a new SocialError that keeps only the code, retry disposition, and upstream HTTP status. The same applies to connection store failures, credential codec failures, and errors from a SQL executor or Redis runner, because each of them can echo tokens, PKCE verifiers, or private provider state. Configure onRefreshFailure and onReconnectRequired to update your application’s reconnect state; callback failures do not replace the refresh result.

Manual rotate calls use the same marker and deadline. While a rotation is in flight, or after one ends uncertain, resolve refuses to return the old token even when it has not expired. Custom credential codecs must preserve refreshState. CredentialManager.revoke calls your provider hook before deleting the revision it read; a replacement saved during revocation is preserved and reported as a conflict. Custom stores must implement deleteIfRevision to use this operation. If they do not, the manager refuses before contacting the provider.

Acquire a shared lock before reading and refreshing the credential, then save against the revision you read. CredentialManager.rotate does this ordering; compare-and-set alone cannot prevent two workers from sending the same rotating token to the provider.

import { CredentialManager, refreshOAuthToken } from "@opencoredev/social-sdk/server";

async function refreshStored(manager: CredentialManager, key: string) {
  return manager.rotate(key, (current) =>
    refreshOAuthToken(
      "tiktok",
      {
        clientId,
        clientSecret,
        signal: AbortSignal.timeout(15_000),
      },
      current,
    ),
  );
}

Construct the manager with your durable credential store and a lock shared by every worker, not a process-local memory lock. Set its lock lifetime longer than the refresh request deadline and renew or fence the lease if work can outlive it. A busy lock raises upstream_failure before calling the provider; reload after the active worker finishes. If persistence fails after rotation, reconcile or reconnect rather than blindly resending the old token. Encrypt stored values and use collision-free keys, such as the encoded identity tuple above.

For Bluesky, apply the same locking and revision checks to the whole BlueskyOAuthSession; the token-only manager does not store its DPoP key.

Last updated on November 5, 2026