Storing OAuth Tokens Safely in a .NET Backend
How I designed encrypted, per-provider token storage and a queued retry worker for a service that publishes to five different social platforms.
Building Social Publisher — a service that lets you write one post and ship it to LinkedIn, Facebook, TikTok, YouTube, and Threads — meant dealing with five different OAuth 2.0 implementations, five different token lifetimes, and one very unforgiving requirement: never lose a user's connection because a token expired at 3am.
The problem with "just store the token"
Every tutorial shows OAuth as a two-step dance: redirect, callback, done. In practice the callback is the easy part. The hard part is what you do with the access token and refresh token afterwards:
- Access tokens expire anywhere from 1 hour (LinkedIn) to 60 days (Facebook long-lived tokens).
- Refresh flows differ enough per-provider that a single "refresh" method quickly turns into a pile of
if (provider == ...)branches. - Tokens are secrets. Storing them in plaintext in a database column is a liability the moment that database is ever exposed, backed up somewhere insecure, or queried by a support tool.
Encrypting at the column, not the disk
Instead of relying on disk-level encryption (which protects against a stolen hard drive but not a SQL injection or an overly curious internal tool), I encrypt token values in the application layer before they ever reach EF Core:
public class EncryptedTokenConverter : ValueConverter<string, string>
{
public EncryptedTokenConverter(IDataProtector protector)
: base(
plain => protector.Protect(plain),
cipher => protector.Unprotect(cipher))
{ }
}
IDataProtector (from Microsoft.AspNetCore.DataProtection) handles key rotation for you — old keys stay around long enough to decrypt existing rows while new writes use the current key. That single decision removed an entire category of "how do we rotate encryption keys without a migration" problems.
A queue instead of an inline refresh
The naive approach refreshes a token the moment a call fails with 401. That works until a provider's refresh endpoint is briefly down and every in-flight publish request starts retrying in a tight loop.
Instead, refresh and publish both go through a background worker with exponential backoff:
- A publish job is enqueued with the connection ID, not the token itself.
- The worker resolves the current token at execution time, refreshing first if it's within a expiry window.
- On failure, the job is re-queued with backoff (30s → 2m → 10m → dead-letter) instead of failing the user-facing request.
This turned "the API call failed" from a user-visible error into an operational metric — connections that need re-authentication surface in a dashboard instead of a support ticket.
What I'd do differently
If I started over, I'd model the refresh-token exchange as its own domain event from day one instead of bolting it onto the publish worker later — the two have different retry semantics (a failed refresh should notify the user; a failed publish should just retry) and untangling them after the fact cost more time than designing for it up front would have.

