Skip to main content
Socialift
Security

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.

How long do access tokens last? Platform table — Capability matrix by platform
Platform / tokenLifetimeRefreshSDK
Facebook user token (short-lived)HoursExchanged server-side for a long-lived token via fb_exchange_tokenfb.exchange_code(...) returns a long-lived token
Facebook user token (long-lived)About 60 daysAn expired token can't be exchanged — the user logs in againfb.granted_scopes(token)
Facebook Page token (from a long-lived user token)No expiration dateNot needed; invalidated under certain conditionsfb.pages(token)
Meta system user tokenNever expires, or 60 daysMeta recommends 60-day tokens: create new, deploy, revoke oldno
Instagram LoginShort-lived 1 hour, long-lived 60 daysTokens at least 24 hours old and not expired refresh for another 60 daysig.refresh_token(token)
TikTokAccess 24 hours, refresh 365 daysThe returned refresh token may differ — use only the new onett.refresh_token(refresh_token)
LinkedInAccess 60 daysRefresh tokens only for approved MDP partners: 365 days, refreshing doesn't extend itli.refresh(...), li.introspect(token)
Telegram bot tokenNo expiry documentedCan be revoked and reissued in BotFather at any timeno
Telegram account (MTProto)Until the session is terminated from active sessionsLog in again (phone → code → 2FA)The export_session() string equals a password
amoCRM OAuthAccess: 24 hours in the docs example; refresh: 3 monthsRefresh token can be exchanged onceno
Bitrix24 OAuthAccess 1 hour, refresh 180 daysVia refresh_tokenno

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

  1. Persist expiry

    On every refresh write expires_at and, if present, refresh_expires_at. Both are ready on TokenBundle.

  2. 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.

  3. 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.

  4. 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.

  5. Classify errors

    AuthError — set status reauth_required and send the user a reconnect link. RateLimitError or TransportError — retry later.

  6. 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.

refresh_worker.py
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.

token_crypto.py
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?

How should a multi-tenant token table look? — Capability matrix by platform
ColumnTypeWhy
tenant_iduuidFilter on every query; tenants' tokens never mix (row-level security)
providertextfacebook, instagram, tiktok, linkedin, telegram_bot, telegram_account
account_idtextPage ID, IG user ID, TikTok open_id, LinkedIn sub; UNIQUE (tenant_id, provider, account_id)
token_kindtextuser, page, system_user, bot, session — drives the refresh rule
access_token_enc, refresh_token_encbyteaEncrypted values only
key_versionintWhich key encrypted it — for rotation
expires_at, refresh_expires_attimestamptzThe scheduler selects by index
scopestext[]Actually granted permissions (not requested ones)
statustextactive, reauth_required, revoked
last_refreshed_at, last_errortimestamptz, textDiagnostics 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.

← Back to blog

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.

Sources

  1. Meta — Long-Lived Access Tokens
  2. Meta — Securing Graph API Requests
  3. Meta — Refresh Instagram Access Token
  4. TikTok — Manage User Access Tokens
  5. LinkedIn — Programmatic Refresh Tokens
  6. cryptography — Fernet

Related pages