Skip to content

Beyond Bearer Tokens: Architecting Secure OAuth Lifecycles & CSRF Protection

OAuth security beyond token storage: CSRF protection, PKCE, AES-GCM encryption, refresh concurrency, and white-labeled OAuth for calendar apps.

Yuvraj Muley Yuvraj Muley · · 15 min read
Beyond Bearer Tokens: Architecting Secure OAuth Lifecycles & CSRF Protection

Engineers often treat OAuth 2.0 as a solved problem. Redirect the user, grab a code, swap it for a token, save it. Done. But protocol correctness doesn't equal system security.

The uncomfortable reality? Most teams nail the happy path in a day, then spend six months fighting token expiry bugs, CSRF edge cases, and silent auth failures. Failing to verify the OAuth state parameter leaves applications wide open to Cross-Site Request Forgery (CSRF) and account takeover.

If you build B2B SaaS integrations, handling enterprise authentication means treating OAuth token lifecycles as a distributed systems problem. At Truto, we maintain connections to a wide range of SaaS platforms. Doing this securely means hardening every step of the handshake, storage, and refresh lifecycle.

Here is how we architected our OAuth infrastructure. No marketing fluff - just a practical reference for building auth at scale.

Info

Link UI SDK + Unified Webhooks: Everything described in this post - CSRF-protected OAuth, encrypted token storage, proactive refreshes - ships as a drop-in experience through Truto's Link UI SDK and unified webhooks. Embed a single authenticate() call in your frontend, and Truto handles the entire OAuth handshake, token lifecycle, and normalized webhook delivery across every connected third-party app.

The vulnerability surface starts before the user even sees a login screen. Exposing raw tenant IDs or environment identifiers in plain-text query parameters invites tampering and enumeration attacks.

Instead of passing raw context, we generate a Link Token. This is a time-bound UUID that securely initiates an OAuth connection for a specific environment or tenant.

  • Hashed Storage: We never store raw tokens. We HMAC-hash them using a dedicated signing key and store the digest in a distributed key-value (KV) store. While attackers with access to the KV store would only see the hash, this defense-in-depth measure mitigates exposure risks but must be paired with strict access controls.
  • Strict Expiration: Link tokens have a hard 7-day Time-To-Live (TTL).
  • Scope Resolution: Scopes are resolved dynamically from the link token, falling back to the unified model, and finally the integration config. URL parameters for scopes are ignored by the system, which helps prevent scope escalation via URL tampering.
  • Time-Bound and Deleted on Success: Link tokens remain valid until they succeed or their TTL expires. Once the OAuth callback completes successfully, the token is deleted. Replaying a successful connection URL gets you nothing.

All of this link token machinery is exposed to your frontend through the @truto/truto-link-sdk package. Your backend generates a link token via POST /link-token, and a single function call opens the OAuth flow for your end user:

import authenticate from '@truto/truto-link-sdk';
 
// Link token generated server-side via POST /link-token
const linkToken = 'your-link-token-uuid';
 
authenticate(linkToken)
  .then((response) => {
    // { result: 'success', integration: 'hubspot' }
    console.log('Connected:', response.integration);
  })
  .catch((error) => {
    // User closed the window or OAuth flow failed
    console.error('Connection failed:', error);
  });

The SDK renders the connection UI inside an iframe by default (or a popup, configurable per your needs). CSRF protection, PKCE, state validation, and encrypted token storage all happen behind the scenes as described in the sections below. Once the connection succeeds, Truto fires an integrated_account:active webhook, and you can immediately start receiving unified webhooks - normalized record:created, record:updated, and record:deleted events - from the connected app.

For reauth scenarios - when a refresh token is revoked and Truto marks the account as needs_reauth - you generate a new link token with the integrated_account_id and call authenticate() again. The user re-authorizes, and Truto resumes token management automatically.

Full quickstart: Link UI SDK documentation

Hardening the Handshake: CSRF, State, and PKCE

This is where most OAuth implementations fail. Developers know about the state parameter, but often treat it as a formality rather than a security artifact.

The State Parameter: More Than a Random String

When a user clicks "Connect," the system must prepare a secure state before redirecting them. Weak state implementations lead directly to account takeovers. Our state generation is paranoid by design:

  1. Generate a cryptographic nonce: We create a random UUID to serve as the state.
  2. Short-lived KV storage: The state is HMAC-hashed with a dedicated OAUTH_STATE_SIGNING_KEY and stored in our KV store with a strict 5-minute TTL. Callbacks that arrive after five minutes are rejected outright.
  3. Secure Cookie Binding: We set a session cookie (truto_oauth_session_state) containing the state value. This cookie is locked down with HttpOnly, Secure, and SameSite=Lax directives, tied strictly to the server's domain.

The callback handler validates the state by checking either the query parameter or the cookie. Then, it looks up the HMAC-hashed version in KV. If the entry is missing, expired, or tampered with, the flow fails.

Warning

Combine a weak redirect_uri with a missing state check, and you have a textbook account takeover. Never treat either as optional.

PKCE: Closing the Code Interception Gap

PKCE (Proof Key for Code Exchange) prevents authorization code interception. At Truto, PKCE is optional per integration config to support legacy providers, but when enabled, we use the S256 method.

Here is the architectural breakdown:

  1. Generate a code_verifier from two concatenated UUIDs for high entropy.
  2. Compute the code_challenge by SHA-256 hashing the verifier and base64url-encoding the result.
  3. Store the code_verifier securely inside the hashed state object in KV, never exposed to the browser.
  4. Send the challenge in the authorization redirect using code_challenge and code_challenge_method=S256.
// Conceptual: PKCE challenge generation
const codeVerifier = `${crypto.randomUUID()}${crypto.randomUUID()}`;
const challengeBuffer = await crypto.subtle.digest(
  'SHA-256',
  new TextEncoder().encode(codeVerifier)
);
const codeChallenge = base64UrlEncode(challengeBuffer);
 
// Stored in the OAuth state for retrieval on callback
oauthState.pkceCodeVerifier = codeVerifier;
 
// Sent in the authorization redirect
authorizeParams.code_challenge = codeChallenge;
authorizeParams.code_challenge_method = 'S256';

The Callback Path and Context Injection

When the provider redirects the user back to our callback endpoint, the system validates the handshake and securely persists the credentials.

Cross-Instance Routing & Static IP Proxying

In a multi-instance deployment, the OAuth callback might hit a different server than the one that started the flow. Our state entry includes a callbackServerUrl. If the callback hits the wrong instance, we transparently redirect it to the correct one. For enterprise providers that require whitelisted IPs, we route the token exchange request through a static IP proxy using custom headers.

Context Injection

After validation, we exchange the authorization code for tokens. Since our architecture handles API requests generically, we merge the resulting payload into an encrypted context object attached to the account.

This context is the single source of truth. During a downstream API call, our engine resolves placeholders like {{oauth.token.access_token}} at runtime, injecting credentials directly into the outgoing HTTP headers.

Security at Rest: AES-GCM Encryption

Tokens are highly privileged credentials. Storing them in plaintext is a massive liability. Protecting data at rest means applying application-level encryption to all sensitive context fields before they ever touch the database disk.

  • Authenticated Encryption: We use AES-GCM (Advanced Encryption Standard with Galois/Counter Mode). This provides both confidentiality and integrity - if an attacker modifies the encrypted blob, decryption fails rather than returning corrupted data.
  • Per-Encryption IVs: We generate a cryptographically secure, random 12-byte Initialization Vector (IV) for every single database write. The encrypted payload is stored in secure *_secret columns (like context_secret) formatted as {iv_base64}::{encrypted_data_base64}. Reusing IVs in AES-GCM destroys its security, so a unique IV per write is non-negotiable.
  • Targeted Redaction: Before any API response is returned to a client, a strict redaction utility strips out sensitive paths (access_token, refresh_token, client_secret).
Layer Mechanism Purpose
Storage AES-GCM with random IV Encryption at rest
API Response Field redaction Prevent token leakage via API
Placeholder Resolution Service-level decryption Tokens decrypted when records are read by internal services
Cookie Security HttpOnly, Secure, SameSite=Lax OAuth state cookie hardening
State/Token Storage HMAC-hashed keys Raw values never persisted in KV

The Token Lifecycle: Mutexes and Proactive Refreshes

Tokens expire. Handling a refresh sounds easy - until three background sync jobs and two user-facing API requests hit the same expired token at the exact same millisecond. Fire five concurrent refresh requests to a provider like Salesforce, and they will likely invalidate the token entirely, suspecting a replay attack.

We solved this with a two-pronged approach to reliable token refreshes:

1. Proactive Refresh Alarms

We proactively schedule refreshes to reduce the chance of hitting a 401 Unauthorized response. Whenever a token updates, we schedule a distributed alarm to fire 60 to 180 seconds before it expires. This randomization spreads the load across our infrastructure. When the alarm fires, a background worker proactively negotiates a new token.

2. Distributed Mutex Locks

For on-demand API calls that happen to catch an expired token, we route the refresh through a per-account distributed mutex (currently enabled only for our sandbox environment).

The mutex is keyed to the specific integrated account. The first request acquires the lock, arms a 30-second watchdog so a hung provider cannot block the account forever, and initiates the HTTP call. If concurrent requests arrive, they see the active lock and simply await the same in-flight operation in memory.

// Conceptual: Mutex-protected token refresh (add a 30s watchdog in production for hung providers)
async acquire<T>(...args: any[]): Promise<T> {
  if (this.operationInProgress) {
    return await this.operationInProgress as T;
  }
 
  this.operationInProgress = (async () => {
    try {
      return await this.performRefresh(...args);
    } finally {
      this.operationInProgress = null;
    }
  })();
 
  return await this.operationInProgress as T;
}

The Reauth State Machine

When a refresh fails, your retry strategy needs to be smart about error types:

  • Retryable errors (HTTP 500+, network failures): A new refresh alarm is scheduled for later.
  • Non-retryable errors (HTTP 401, 403): The alarm is deleted entirely. No amount of retrying fixes hard authorization failures. The account is marked as needs_reauth, and a webhook fires so your application can prompt the user to reconnect.

Bring Your Own OAuth App (White-Label) for Calendar and Every Other Integration

Short answer to the most common question: yes, Truto supports white-labeled OAuth for calendar apps - Google Calendar, Microsoft Outlook Calendar, Calendly, Cal.com, and every other OAuth-based integration in the catalog. You bring your own OAuth client credentials, and your end users see your brand on the provider's consent screen instead of Truto's.

What BYO OAuth means in Truto

By default, Truto ships with pre-configured OAuth clients for each integration. This is the fastest way to build a demo - users click "Connect Google Calendar," and the flow works immediately. But in production, most teams want end users to see their company name and logo on the Google or Microsoft consent screen, not Truto's.

Bring Your Own OAuth (BYO OAuth) solves this. You register an OAuth app with the provider (Google Cloud Console, Microsoft Entra, Calendly Developer Portal, Cal.com developer settings), then plug the client ID and secret into Truto. From that moment on, every user connecting through your Link UI SDK sees your brand on the consent screen, and the resulting tokens are issued to your OAuth app.

Truto's credential hierarchy makes this a configuration change, not a code change. Credentials resolve through three levels:

  1. Integration-level - Truto's default OAuth app (used if you don't override).
  2. Environment Integration-level - Your custom OAuth app for a specific environment (production, staging, sandbox).
  3. Integrated Account-level - Rare per-account overrides for enterprise customers who insist on their own tenant apps.

Environment-level overrides are the sweet spot for white-labeling: one place to manage OAuth apps per environment, applied automatically to every connection made in that environment.

Step-by-step: Add your OAuth client to Truto

Setup takes about five minutes per provider once you have the client credentials in hand.

1. Register the OAuth app with the provider. Create the app in the provider's developer console. Set the redirect URI to Truto's callback URL for your region - the Truto dashboard shows you the exact value when you open the integration settings. Copy it verbatim; the provider matches it character-for-character.

2. Open the integration in the Truto dashboard. Navigate to Dashboard → Environments → [your environment] → Integrations → [Provider name] → OAuth App. For example, Environments → Production → Integrations → Google Calendar → OAuth App. This is the environment-integration-scoped configuration screen.

! Truto dashboard: Environments → Integrations → Google Calendar → OAuth App configuration screen

3. Enter your Client ID and Client Secret. The form has these fields:

Field Where it goes
Client ID The OAuth 2.0 Client ID issued by the provider
Client Secret The corresponding client secret (encrypted at rest with AES-GCM, as described above)
Scopes Optional override of the default scope list
Optional scopes Scopes the user can grant but that aren't required for connection
Redirect URI Read-only display of the value you must register with the provider

Sample values look like this:

Client ID:     123456789012-abc123def456.apps.googleusercontent.com
Client Secret: GOCSPX-••••••••••••••••••••••••
Scopes:        https://www.googleapis.com/auth/calendar.events, openid, email, profile

Save. Truto encrypts the client secret through the same AES-GCM pipeline described earlier - it's stored in the _secret column with a per-write IV, never in plaintext.

! Truto dashboard: OAuth App form with Client ID, Client Secret, Scopes, and Redirect URI fields

4. Test the connection. Generate a link token, open the Link UI SDK in test mode, and verify the consent screen shows your app name and logo. Confirm the redirect completes without a redirect_uri_mismatch error.

5. Migrate existing accounts if you already have production connections using Truto's default OAuth app. See the migration notes below.

Per-provider scope recommendations

Calendar providers have wildly different scope models. Request only what you need - overly broad scopes will fail Google's verification review and raise flags during enterprise procurement.

Google Calendar

Scope Purpose
https://www.googleapis.com/auth/calendar.events Read/write events on calendars the user owns
https://www.googleapis.com/auth/calendar.readonly Read-only access if you don't create events
https://www.googleapis.com/auth/calendar.calendarlist.readonly List the user's calendars
https://www.googleapis.com/auth/calendar.freebusy Narrower scope for scheduling-only use cases
openid email profile User identity

If you only need free/busy for scheduling, calendar.freebusy is much easier to get verified than the full events scope.

Microsoft Outlook Calendar (Microsoft Graph)

Scope Purpose
Calendars.ReadWrite Read/write user calendars and events
Calendars.Read Read-only alternative
MailboxSettings.Read Timezone and working-hours metadata
User.Read Basic user profile
offline_access Required to receive a refresh token

Do not omit offline_access - without it, Microsoft won't issue a refresh token and every session re-prompts the user.

Calendly

Calendly uses OAuth 2.0 with scopes configured on the app itself in the Calendly Developer Portal, not per authorization request. Register your app as a public integration if you plan to distribute it to multiple customers. Common resources requested: user, event types, scheduled events, and organization membership. Truto handles the token exchange and refresh cycle the same way it does for Google or Microsoft.

Cal.com

Cal.com Cloud supports OAuth for managed accounts; self-hosted Cal.com instances typically use API-key auth. For the OAuth flow, the standard read/write booking and profile scopes cover most calendar use cases. Truto's credential hierarchy lets you mix OAuth (for Cal.com Cloud) and API-key auth (for self-hosted) in the same environment without any code changes.

Once BYO OAuth is configured, this is what changes for the end user:

  • App name on the consent screen shows your company name, not Truto's.
  • App logo is whatever you uploaded to the provider's developer console.
  • Publisher / verification badge reflects your organization's verification status with the provider (verified publisher, CASA-audited, etc.).
  • Requested scopes are the ones you configured, rendered in the provider's native UI (Google's account chooser, Microsoft's consent card, etc.).
  • Redirect URL still points to Truto's callback host. This is invisible to users but visible in browser DevTools if someone inspects the request.

The Link UI SDK itself is already white-label friendly: it renders inside an iframe or popup you control, with theming options for the pre-consent screen. Combine BYO OAuth with SDK theming, and the entire flow reads as a first-party feature of your product.

! Google consent screen showing your app's name and logo instead of Truto's

Verification, migration, and token ownership

Token ownership. Tokens issued under your OAuth app belong to your app registration, not Truto's. If you ever leave Truto, the tokens are still valid against your OAuth app - you retain the ability to migrate off without forcing every user to re-authorize. Under Truto's default OAuth app, tokens are tied to Truto's registration and cannot be exported.

Google verification and CASA. Google requires apps requesting sensitive or restricted scopes (most calendar scopes qualify) to pass an OAuth verification review. For restricted scopes, this includes an annual Cloud Application Security Assessment (CASA) audit - Tier 2 for most calendar apps, or Tier 3 if you handle very large user bases or high-risk scopes. Budget 4-8 weeks for the initial review and plan for the annual re-audit. Truto's default OAuth apps are already verified, so if verification timelines are blocking a launch, ship with Truto's default app first and migrate to your own after verification completes.

Microsoft publisher verification. For multi-tenant Microsoft apps, Microsoft requires publisher verification tied to your Microsoft Partner Network account. Without it, enterprise admins see an "unverified publisher" warning on the consent screen and may block the install entirely.

Calendly and Cal.com. Neither requires a formal verification process comparable to Google or Microsoft. Calendly does gate public (multi-customer) OAuth apps behind an application review; Cal.com Cloud OAuth apps are self-service.

Migrating from Truto's default OAuth app to yours. Refresh tokens are bound to the OAuth client that issued them, so existing connections cannot be silently transferred. The migration path is:

  1. Configure your OAuth app in a staging environment first.
  2. Test end-to-end with internal users.
  3. Flip the environment-integration override on your production environment.
  4. Existing connections keep working against Truto's default app until their refresh tokens are revoked or expire; new connections use your app.
  5. For accounts that need to switch immediately, mark them as needs_reauth. Truto fires the integrated_account:authentication_error webhook, and the next Link SDK reconnection issues tokens under your app.

Troubleshooting and FAQs

Does Truto support white-labeled OAuth for calendar apps? Yes. Google Calendar, Outlook Calendar, Calendly, Cal.com, and every other OAuth-based calendar integration in Truto's catalog supports BYO OAuth via environment-integration overrides.

"Redirect URI mismatch" error after saving my client ID. The redirect URI you registered with the provider must match Truto's callback URL exactly, including trailing slashes and the https scheme. Copy the value from the Truto dashboard rather than typing it.

Users see an "unverified app" warning. Your OAuth app hasn't completed the provider's verification review yet. This is expected during development. For production, submit for verification in Google Cloud Console or Microsoft Entra. If verification timelines are blocking you, ship on Truto's pre-verified default OAuth app first, then migrate.

Refresh tokens are getting invalidated after a few days. For Google, this usually means the app is still in "Testing" mode - refresh tokens for test users expire after seven days. Move the app to "In Production" (which requires verification for sensitive scopes) to get long-lived refresh tokens. For Microsoft, confirm you requested offline_access.

Can I use different OAuth apps for staging vs production? Yes. Environment-integration overrides are scoped per environment, so staging can use one OAuth app (or Truto's default) while production uses your verified app.

Do I lose existing connections if I switch to my own OAuth app? Existing tokens keep working until their refresh tokens are revoked or expire. New connections use your OAuth app. Use the needs_reauth flow above to force-migrate specific accounts immediately.

Can I white-label the Link UI itself, not just the consent screen? Yes. The Link UI SDK supports theming, custom colors, and iframe embedding. Combined with BYO OAuth, the entire connection flow looks like a first-party feature.

Security as Data, Not Code

Implementing strict CSRF protection, AES-GCM encryption, and distributed mutexes from scratch for one integration is painful. Doing it for a large ecosystem of SaaS platforms is a maintenance nightmare. This is exactly why teams are abandoning point-to-point connectors.

Our architectural philosophy is simple: handle security declaratively. The complex mechanics of PKCE, state validation, token concurrency, and white-labeled OAuth clients live entirely within our generic execution pipeline. Adding a new integration - or swapping in your own OAuth credentials - becomes a matter of configuration, not writing bespoke authentication handlers.

Treat token lifecycle management as a fundamental infrastructure primitive, not an afterthought. That way, developers stop fighting OAuth edge cases and get back to building the actual product.

FAQ

Does Truto support white-labeled OAuth for calendar apps?
Yes. Google Calendar, Microsoft Outlook Calendar, Calendly, Cal.com, and every other OAuth-based calendar integration in Truto's catalog supports Bring Your Own OAuth. You register an OAuth app with the provider, plug the client ID and secret into Truto's dashboard at the environment-integration level, and end users see your brand on the consent screen instead of Truto's.
How do I add my own OAuth client to Truto?
Register an OAuth app in the provider's developer console (Google Cloud Console, Microsoft Entra, Calendly Developer Portal, etc.), copy Truto's callback URL as the redirect URI, then navigate to Dashboard → Environments → [environment] → Integrations → [Provider] → OAuth App and enter your Client ID, Client Secret, and any scope overrides. The client secret is encrypted at rest with AES-GCM.
What scopes should I request for Google Calendar and Outlook white-label OAuth?
For Google Calendar: calendar.events (read/write) or calendar.readonly, plus openid, email, and profile. Use calendar.freebusy for scheduling-only use cases to simplify verification. For Microsoft Outlook via Graph: Calendars.ReadWrite (or Calendars.Read), MailboxSettings.Read, User.Read, and critically offline_access - without offline_access Microsoft will not issue a refresh token.
Do I lose existing connections if I switch from Truto's default OAuth app to my own?
Refresh tokens are bound to the OAuth client that issued them, so existing connections continue working against Truto's default app until their refresh tokens are revoked or expire. New connections use your OAuth app immediately. To force-migrate specific accounts, mark them as needs_reauth - Truto fires an authentication_error webhook and the next Link SDK reconnection issues tokens under your new app.
What are Google's verification requirements for calendar OAuth apps?
Google requires OAuth verification for apps requesting sensitive or restricted scopes, which includes most calendar scopes. Restricted scopes also require an annual CASA (Cloud Application Security Assessment) audit, typically Tier 2 for calendar apps. Budget 4-8 weeks for initial review. Truto's default OAuth apps are pre-verified, so you can ship with them and migrate to your own after your verification completes.

More from our Blog