Storing and refreshing social media OAuth tokens securely
Keep OAuth tokens on the server only, encrypted at rest (Fernet or KMS), in one row per tenant and account. Track expiry via expires_at and refresh with a background worker before tokens lapse. Platforms differ: TikTok access tokens live 24 hours, Instagram Login and LinkedIn tokens 60 days, and Facebook Page tokens have no expiry date. AuthError and webhooks reveal revocation.
Updated:
Where and how should you store OAuth tokens?
A token is a password. Never ship it to a browser, mobile app or log: store it in an encrypted database column with the encryption key kept elsewhere (environment variable, secret manager or KMS). Persist each token's expires_at, actually granted scopes and status. A scheduler handles refresh, and revocation surfaces through AuthError and platform webhooks.
The Socialift SDK persists nothing: exchange_code() and refresh_token() return a TokenBundle (access_token, refresh_token, expires_in, expires_at), and where it goes is your application's call. In the Socialift dashboard's Ulanishlar (Connections) section tokens are encrypted server-side and never sent to the browser — apply the same rule in your own backend.
How long do access tokens last? Platform table
Per official docs as of 2026-09-17. Always compute expiry from expires_in in the response — don't hard-code the numbers below.
| Platform / token | Lifetime | Refresh | SDK |
|---|---|---|---|
| Facebook user token (short-lived) | Hours | Exchanged server-side for a long-lived token via fb_exchange_token | fb.exchange_code(...) returns a long-lived token |
| Facebook user token (long-lived) | About 60 days | An expired token can't be exchanged — the user logs in again | fb.granted_scopes(token) |
| Facebook Page token (from a long-lived user token) | No expiration date | Not needed; invalidated under certain conditions | fb.pages(token) |
| Meta system user token | Never expires, or 60 days | Meta recommends 60-day tokens: create new, deploy, revoke old | no |
| Instagram Login | Short-lived 1 hour, long-lived 60 days | Tokens at least 24 hours old and not expired refresh for another 60 days | ig.refresh_token(token) |
| TikTok | Access 24 hours, refresh 365 days | The returned refresh token may differ — use only the new one | tt.refresh_token(refresh_token) |
| Access 60 days | Refresh tokens only for approved MDP partners: 365 days, refreshing doesn't extend it | li.refresh(...), li.introspect(token) | |
| Telegram bot token | No expiry documented | Can be revoked and reissued in BotFather at any time | no |
| Telegram account (MTProto) | Until the session is terminated from active sessions | Log in again (phone → code → 2FA) | The export_session() string equals a password |
| amoCRM OAuth | Access: 24 hours in the docs example; refresh: 3 months | Refresh token can be exchanged once | no |
| Bitrix24 OAuth | Access 1 hour, refresh 180 days | Via refresh_token | no |
yesnoScroll the table sideways
Notes and limits
Meta also has data access expiration: if a user is inactive for 90 days, data is withheld even with a valid token (some business permissions are exempt). debug_token reports the status.
Refresh strategy: when and how to refresh tokens
Persist expiry
On every refresh write
expires_atand, if present,refresh_expires_at. Both are ready onTokenBundle.Schedule by window
For example, hourly: TikTok with under 4 hours left, Instagram Login with under 10 days left (token at least 24 hours old), LinkedIn with a refresh token and under 7 days left.
One token, one refresher
Lock the row. TikTok and amoCRM rotate refresh tokens: if two workers refresh in parallel, the second fails with a stale token.
Write atomically
Save the new access and refresh token in one transaction. If the write fails, the new refresh token is lost and the account disconnects.
Classify errors
AuthError— set statusreauth_requiredand send the user a reconnect link.RateLimitErrororTransportError— retry later.Warn early where there's no refresh
For Facebook user tokens or LinkedIn without refresh tokens, prompt the user to reconnect 7–14 days before expiry.
Refresh worker (Socialift SDK)
One client per provider is enough: the token is passed to every method and never stored on the client.
from socialift import ApiError, AuthError, Instagram, LinkedIn, RateLimitError, TikTok
async def refresh_row(row, tt: TikTok, ig: Instagram, li: LinkedIn) -> None:
try:
if row.provider == "tiktok":
bundle = await tt.refresh_token(decrypt(row.refresh_token_enc))
elif row.provider == "instagram":
bundle = await ig.refresh_token(decrypt(row.access_token_enc))
else:
bundle = await li.refresh(decrypt(row.refresh_token_enc))
except RateLimitError:
raise # retry later
except ApiError as exc:
if not isinstance(exc, AuthError) and exc.status != 400:
raise
await db.set_status(row.id, "reauth_required")
return
await db.save_bundle( # one transaction
row.id,
access_token_enc=encrypt(bundle.access_token),
refresh_token_enc=encrypt(bundle.refresh_token) if bundle.refresh_token else None,
expires_at=bundle.expires_at,
)Under the OAuth standard a rejected refresh token (invalid_grant) usually comes back as HTTP 400, not 401 — so that case also goes to reconnect.
Have db.save_bundle keep the existing refresh token when it receives None: Instagram's refresh response has no refresh token. encrypt/decrypt are the Fernet helpers below.
Encrypting tokens at rest: Fernet and key rotation
cryptography is already an SDK dependency. Fernet provides authenticated encryption (AES-128-CBC + HMAC-SHA256); MultiFernet enables key rotation.
import os
from cryptography.fernet import Fernet, MultiFernet
# TOKEN_KEYS="new_key,old_key" — the first key encrypts
_keys = MultiFernet([Fernet(k) for k in os.environ["TOKEN_KEYS"].split(",")])
def encrypt(token: str) -> bytes:
return _keys.encrypt(token.encode())
def decrypt(blob: bytes) -> str:
return _keys.decrypt(blob).decode()
def reencrypt(blob: bytes) -> bytes:
"""Background migration after a key change: re-encrypts under the new key."""
return _keys.rotate(blob)Rotating: prepend the new key, run every row through reencrypt, then drop the old key. With a KMS use envelope encryption: the KMS encrypts a data key, the data key encrypts tokens.
How should a multi-tenant token table look?
| Column | Type | Why |
|---|---|---|
| tenant_id | uuid | Filter on every query; tenants' tokens never mix (row-level security) |
| provider | text | facebook, instagram, tiktok, linkedin, telegram_bot, telegram_account |
| account_id | text | Page ID, IG user ID, TikTok open_id, LinkedIn sub; UNIQUE (tenant_id, provider, account_id) |
| token_kind | text | user, page, system_user, bot, session — drives the refresh rule |
| access_token_enc, refresh_token_enc | bytea | Encrypted values only |
| key_version | int | Which key encrypted it — for rotation |
| expires_at, refresh_expires_at | timestamptz | The scheduler selects by index |
| scopes | text[] | Actually granted permissions (not requested ones) |
| status | text | active, reauth_required, revoked |
| last_refreshed_at, last_error | timestamptz, text | Diagnostics and alerts (without the token itself) |
App secrets, client secrets and encryption keys never go in this table — they belong in a secret manager or environment variables.
What is appsecret_proof and why use it?
appsecret_proof is an HMAC-SHA256 of the access token keyed with your app secret, sent as a Graph API parameter. Even if a token is stolen, nobody can build the proof without the app secret.
With "Require App Secret" enabled in the App Dashboard (App Settings → Advanced → Security), calls without a proof fail — so client-side calls must be proxied through your backend. The Socialift SDK adds the proof to Meta requests automatically (Facebook, Instagram, Ads); without the SDK compute it with socialift.crypto.appsecret_proof(access_token, app_secret) or hmac.
How do you detect a revoked token?
Meta: code 190 (expired or invalidated token) surfaces in the SDK as AuthError. Subcodes give the reason: 458 app not installed, 460 password changed, 463 expired, 467 invalid token. When a user removes your app on Facebook itself, Meta pings the deauthorize callback URL set in the App Dashboard; verify its signed_request with parse_signed_request(signed_request, app_secret).
TikTok sends an authorization.removed webhook when access is revoked (verify with verify_tiktok_request). On LinkedIn, li.introspect(token) returns the token status (active, expired, revoked), and a 401 becomes AuthError. If a Telegram bot token is revoked in BotFather, the old token stops working — the SDK turns the Unauthorized response into AuthError.
When a user clicks "disconnect" in your app, revoke at the provider first (fb.revoke(token), tt.revoke(token)), then delete the row. Deleting from your database alone isn't enough: the Meta token keeps working.
Least-privilege scopes and secret management
- Request only the scopes a feature needs — App Review and audits check exactly that.
- Users can decline some permissions — store what was actually granted:
fb.granted_scopes(token),li.introspect(token)on LinkedIn. - Separate apps and keys per environment (dev, staging, prod); production tokens never land in a dev database.
- Plan app secret rotation ahead: appsecret_proof and webhook signature checks depend on it — update every service together.
- Keep tokens out of logs, Sentry and analytics; mask them in error messages too.
- Encrypt the Telegram account session string (
export_session()) exactly like a token. - Rotate the encryption key at least yearly and when staff leave (MultiFernet or KMS).
- Surface accounts that need reconnecting in your UI — a silent disconnect is the most expensive failure.
Frequently asked questions
Can I store OAuth tokens in plain text in my database?
It's not recommended. A token leaked through a database backup or SQL injection is usable immediately. Keep tokens in an encrypted column and the key outside the database, so the database alone is not enough for an attacker.
When should I refresh an Instagram token?
A long-lived Instagram Login token lasts 60 days and can be refreshed once it is at least 24 hours old and not expired, for another 60 days. In practice, refresh with a background worker 7–10 days before expiry. Page tokens obtained via Facebook Login don't need refreshing.
Why does my TikTok refresh token fail?
Usually because an old refresh token was reused. TikTok's docs say the refresh token in the response may differ and you must use the new one. Persist it atomically and never let two workers refresh the same token in parallel.
Why doesn't LinkedIn return a refresh token?
LinkedIn issues programmatic refresh tokens only to approved Marketing Developer Platform partners. For other apps the access token lasts 60 days, after which the user must authorise again — warn them ahead of time.
Do Facebook Page access tokens expire?
A Page token obtained from a long-lived user token has no expiration date, but it is invalidated under certain conditions — for example when the user changes their password or removes the app. Watch for AuthError and store the status.
Is appsecret_proof mandatory?
If "Require App Secret" is enabled in the App Dashboard, yes — calls without a proof fail. Even without that setting, adding it to server-side calls is recommended. The Socialift SDK adds it to Meta requests automatically.