Guides

Challenges Building an Authentication Layer for Integrations

The real challenges of building auth for your SaaS product's native integrations: OAuth variability, multi-tenant scale, and OIDC confusion.

Garrett Scott
,
Head of Marketing

Challenges Building an Authentication Layer for Integrations

A customer connects their own Salesforce, Slack, or Jira account to your product and expects two things: to be live in under a minute, and for that connection to still work a year from now without their ever thinking about it again. Getting there starts with a question most teams don't ask until they're already stuck on it: are you authenticating as your own app, as one shared service account across every customer, or as that specific end user? We built the third model, per-end-user managed authentication, into Paragon Auth years ago, and we run it today across hundreds of integrations plus any custom connector you build through the Custom Integration Builder. Your team doesn't have to build that identity layer first.

Native integrations with popular apps look simple until you reach that first step. I've watched teams spend weeks there before writing a single line of integration logic that isn't authentication—and the version of "authenticate" they build first is usually the wrong shape for a multi-tenant product. What follows is a field guide to that shape: the three ways to authenticate to your users' integrations, what each one obligates you to build, and the specific challenges that surface once you're doing it at scale.

How do I authenticate to my users' integrations?

Authenticating to your users' integrations means running, for every third-party app you support, an authorization flow, a credential store, and a refresh loop that survives years of provider changes without breaking a customer's connection. The decision that shapes everything downstream is who you're authenticating as, covered next. We run the per-end-user version of that already, across hundreds of integrations plus anything you build through the Custom Integration Builder.

Three ways to authenticate to your users' integrations

Every integration authenticates as one of three identities. I've seen the choice get made by accident more often than by design, and it determines what breaks later and who notices when it does.

As your app. Your application authenticates with credentials it owns itself: an API key or OAuth client that belongs to you, never to a customer. This works for integrations acting on your behalf—pulling from a shared data source, calling a platform API with no concept of end users. It stops working the moment a customer expects the integration to see their own Salesforce, their own Slack workspace, their own data.

A shared service account. One privileged account, provisioned by your team or a customer's admin, authenticates on behalf of everyone at that customer. It's faster to stand up than per-user auth: one OAuth flow, one token to refresh, one place credentials live. The tradeoff is scope: every action your product takes for any user at that customer runs under one identity, with no way to attribute it to the person who took it, and losing that one account — deprovisioned, deauthorized, doesn't matter which — drops the connection for the whole customer at once.

Per-end-user. Each of your customer's own users connects their own account to the third-party provider, and your product authenticates, stores credentials, and acts as that specific person. Customer-facing integrations raise the stakes on every failure mode enough that this is the model most customer-facing products end up needing: actions attributed to the person who took them, permissions scoped to what that person can actually see, and a connection that survives one user leaving without taking the rest of the company down with it.

Per-end-user is also the model with the most to build. It doesn't stop at running the OAuth flow once per person — see the obligations that come with it below, and how Paragon absorbs them, both here and as part of the broader AI agent integration infrastructure a customer-facing product needs underneath it.

Auth <> Security

Storing credentials for every user's connected account means storing access to whatever that account reaches: financial records in an accounting system, private messages in Slack or Teams, employee data in an HR platform. Those tokens function like passwords and need the same protection—never plaintext in a database table anyone with read access can query.

In April 2022, Heroku was compromised and their users' GitHub integration OAuth access tokens were stolen, breaching and cloning several private repositories; npm's data was harvested in the same incident, covered at the time by Forbes and others.

Paragon's Approach to Securely Storing Credentials

We built this assuming the worst case: a compromised database that still doesn't hand an attacker usable credentials.

1. Encryption & Storage

We ensure that your customers' integration credentials are symmetrically encrypted before they are stored.

Encryption keys are stored independently in a separate database, and whenever we need to access the decrypted credentials to make an API call, our Workflow service will fetch the encrypted value and the associated encryption key and decrypt it locally in memory.

2. Penetration tests

We regularly pen test our infrastructure to ensure that it's equipped to prevent attackers from getting unauthorized access to both our customers' and their users' credentials.

3. Isolation and revocation

Choosing the per-end-user model from the section above doesn't stop at running the OAuth flow once per person. It obligates you to build three more things a shared-service-account model can skip: per-credential storage, one encrypted credential per user per provider rather than one per customer; isolation between tenants' credentials, so a bug or a compromised key tied to one customer's data can't expose another's; and a revocation path, so when a user disconnects, deletes their account, or gets deprovisioned, their credential, and only theirs, stops working immediately, without a deploy.

The encryption and storage approach above is built for exactly that: each credential is encrypted at rest in an isolated vault, with the encryption keys and encrypted values stored separately and Paragon-managed. For the storage architecture in depth, see our deep dive on multi-tenant OAuth token storage.

Connect Portal and the per-tenant model

The per-end-user model still needs a connection screen, somewhere your customer's end user authorizes their Salesforce, Slack, or Jira account. We built Connect Portal to be that screen: an embeddable, per-tenant UI your product surfaces to each of your customers' users, letting them authenticate through the provider's own OAuth flow without your team building a custom auth page per integration.

Here's the flow: you embed Connect Portal in your product, an end user clicks to connect a provider and completes that provider's own OAuth screen, we store the resulting credential in the isolated, per-user vault described above, and your product calls that provider's API on the user's behalf through the same credential. If the connection fails or gets revoked, we surface that failure back to your product and to the user—it doesn't fail silently in a background job. We absorb both that per-user auth layer and the managed connectors on top of it: connector maintenance, handling a provider's own API and auth changes over time, isn't work your team owns per integration.

Challenges with OAuth

While many services do use API Keys to authenticate requests, most of the top SaaS companies use OAuth 2.0 to authorize requests to their API instead.

Generally, implementing OAuth based authorization flows for integrations involves setting up services to handle:

  • The initial authorization request to get the access and refresh tokens

  • Storage of the access and refresh tokens

  • Authenticating requests to the 3rd party API with the access tokens

  • Using the refresh tokens to get new access tokens

Many teams initially think that since OAuth 2.0 is a 'standard', it would be trivial to implement across dozens of integrations.

However, there are many hidden challenges that need to be overcome in order to build an auth layer for your integration roadmap — challenges that even our own team didn't anticipate when we initially set out to build Paragon a few years ago, and spent years of dedicated engineering to solve.

Let's talk about some of those challenges.

An Unstandardized Standard

The main issue with OAuth is that it isn't really a protocol. It's a skeleton of a protocol, and every app developer fills in the rest according to their own interpretation — obvious the moment you compare any two providers' API documentation.

Here are just a few of the many examples we've run into:

  • The state parameter in the OAuth authorization request should support URL-encoded values, but on X (formerly Twitter), developers have reported, in X's own developer community forum, characters getting stripped from it after redirect — a forum-reported quirk that X's own documentation doesn't confirm.

  • While you have to specify the scope for most apps, Mailchimp and Notion don't use them.

  • Every app can have very different refresh token policies. Google's classic offline-access refresh tokens don't rotate on each use, but they are neither unlimited nor eternal: apps whose consent screen is still in Testing status get refresh tokens that expire in seven days, and Google caps them at 100 per account per OAuth client ID. But for Salesforce, each refresh token expires on a user-configurable basis and each user gets only five approvals per connected app.

  • Some apps require a Proof of Key for Code Exchange (PKCE), which adds additional requirements and steps in the Authorization Code Flow, while others do not.

That variability doesn't stop at how a provider implements OAuth today, either — providers revise those implementations on a timeline you don't control. Salesforce's move toward mandatory Refresh Token Rotation and Microsoft Entra's fixed, non-configurable token lifetimes are two current examples: the flow you built against a provider's documentation six months ago may not be the flow that provider runs today, often with no changelog entry you'd notice until a customer's connection breaks. Google's own refresh-token rules shift on that same unannounced timeline. (Handling a rotation or expiry once you already know about it is a narrower, different problem — see our breakdown of how far a provider's token policy can drift before you notice.)

The list goes on, but the greatest challenge is handling token refresh.

Complexities with Refreshing Tokens

Under OAuth, access tokens typically have a time-to-live or TTL (the expires_in parameter of a token response) before expiring and becoming invalid.

When it expires, new access tokens can be obtained using the provided refresh token (as shown in the diagram earlier).

To prevent your users' access tokens from expiring (which will break the connection, cause requests to fail, and inconveniently require your user to authenticate again), your authentication service needs to refresh them periodically in the background. But how?

Approach #1: Refreshing before every Request

The easiest implementation is to get a new access token using the refresh token each time when an API call is made.

But as you can imagine, this scales poorly because you would have to double the number of requests your integration services need to make, which can easily lead to rate-limiting and load balancing issues.

Additionally, with integration use cases that don't run jobs in the background (such as user-triggered workflows), longer durations of inactivity can lead to even refresh tokens expiring.

Approach #2: Refreshing Periodically in the Background

So instead of refreshing before every request, we landed on a much more reliable, if more complex, approach: refresh tokens periodically, on a schedule of our own, independent of when a request happens to come in.

This approach resolves the two issues we outlined earlier — running into rate limits and tokens expiring because of inactivity.

However, implementing this into our auth infrastructure was significantly more complex than the first approach, as it led to us having to handle many complications and edge cases, including:

  • Differing refresh policies

  • Preventing race conditions

  • Forced De-authorization

  • Ambiguous errors

Differing Refresh Policies

To start, each app you want to integrate with may have implemented the token refresh flow differently.

Some apps let you keep your existing refresh token indefinitely.

  • ie. Google, where a single refresh token keeps working until it is revoked or hits a cap

Some apps rotate your refresh token out from under you on their own schedule.

  • ie. QuickBooks, which rotates in a new refresh token roughly once every 24 hours of use rather than keeping one static

Some apps limit how many refresh tokens you can generate per organization

  • ie. Salesforce allows five approvals per user per connected app — issue a sixth and the oldest is revoked

Some apps have expiring refresh tokens (expiry completely up to the app developer).

  • ie. NetSuite's refresh tokens are one-time-use and expire after two days by default (configurable up to 30 days on the integration record), while Microsoft Entra — which fronts Outlook — sets no fixed expiry at all, only a rolling inactivity window.

Some apps have different Inactivity and Absolute Expirations

  • ie. Jira's rotating refresh tokens expire after 90 days of inactivity (each use resets the clock), while QuickBooks layers a flat five-year absolute maximum on top of rotation — hit it and your users re-auth no matter what

While each of these adhere to the general OAuth standard, you can't reuse the same approach to handle every integration's auth. Not accounting for all the edge cases can lead to many production-level challenges with your integration after going live.

That's why our integrations engineering team had to become OAuth experts in order to build the unified layer for auth that all our customers rely on for their products' native integrations.

On the bright side, this led to Paragon releasing its Custom Integration Builder which enables customers to rely on our authentication service for any native integration, beyond our hundreds of pre-built connectors.

Preventing Race Conditions

If you're able to comprehensively handle the complications with the varying refresh policies, next comes the challenge of preventing race conditions when refreshing tokens. Never fun to deal with when it comes to distributed systems.

Just as one example, if a token is being refreshed, but a concurrent request is made to the 3rd party API, what do you do?

Under rotation schemes with reuse detection, accidentally using a stale token doesn't just fail that one request — the provider can treat the reuse as possible theft and invalidate the whole token family, including the one mid-refresh.

To prevent race conditions, refresh and outbound calls to that provider get coordinated so a request doesn't run against a token that's mid-refresh — getting that coordination right, consistently, across every provider, is the part that's genuinely hard to build yourself.

Handling Forced De-authorization

If that wasn't enough, you have to also deal with forced de-authorization from the app's side (which is more common than we expected). For example, we've seen vigilant Salesforce and Google Workspace admins manually revoke several connected apps at once. Since the app is deauthorized, it's not always reliable to depend on the access token TTL to check its validity.

As mentioned earlier, we can't rely on the TTL alone: catching a forced de-authorization means testing a live token directly against the provider, since a revoked token can still show as unexpired.

For Jira Cloud, for example, [.inline-code-highlight]GET /rest/api/3/mypreferences/locale[.inline-code-highlight] is a cheap way to do that — a 200 response means the token is still authorized; anything else means it's time to fall back to the refresh token. Every provider needs its own version of that check, since there's no standard endpoint for confirming a token is still good.

Ambiguous Authentication Errors

Finally, debugging auth errors. There are very few services that we've built integrations for where we felt that they provided sufficient explanations as to why an error occurred, and in most cases the 3rd party app's API docs completely lack details on auth errors, or provide very generic and unhelpful resources.

To make our OAuth client reliable and make debugging easier, we needed to be able to identify which errors are recoverable and which are not.

While OAuth outlines standardized errors, which are invalid_request, invalid_client, invalid_grant, unauthorized_client, unsupported_grant_type, and invalid_scope, due to all the different policies listed earlier, it is incredibly difficult to debug, especially across dozens of services.

This led us to creating a repository of error responses such that our auth service can identify which errors are recoverable and which ones aren't, which has taken years to compile and is constantly being updated as changes are made to the 3rd party app's API and authentication flow.

Multi-tenant authentication multiplies every one of these problems

Everything above gets harder once you're authenticating more than one customer to more than one provider. Multi-tenant authentication turns a manageable N×M problem, N customers times M third-party providers, into N×M separately live credentials, each with its own refresh clock, forced-de-authorization risk, and history of ambiguous errors. I've watched a race condition that shows up once in a million refreshes for one customer start showing up daily once you have a few thousand customers on a few dozen providers. At that point the failure modes above stop being things you debug occasionally and become things you monitor continuously, because something is always mid-refresh, mid-race, or mid-de-authorization. For the architecture that isolates one customer's credentials from another's at that scale, see how to build multi-tenant auth for customer-facing integrations.

Customer-facing integrations raise the stakes on every failure mode above

An internal, system-to-system integration fails quietly: your own team notices and fixes it. A customer-facing integration doesn't get that grace period. I've watched this pattern play out enough times to trust it: a broken connection surfaces as a support ticket before your monitoring even catches it. That has two consequences. First, one OAuth app registration serves every customer on a given provider, so a single provider policy change lands on all of them simultaneously. Second, customers expect a broken connection to be your product's problem to surface, before they discover it themselves when a workflow silently stops running. Building that status visibility, and the support tooling behind it, is its own project on top of everything else in this article.

OAuth and OIDC solve different problems, and conflating them is a common, costly mistake

OAuth 2.0 and OIDC get treated as interchangeable more often than they should be. They aren't. OAuth is an authorization framework: it tells you what an app can do on a user's behalf. OIDC is, in the specification's own words, "a simple identity layer on top of the OAuth 2.0 protocol," adding the pieces OAuth alone never provides: a signed ID Token asserting who authenticated, a UserInfo endpoint for profile claims, and a standard set of claims every provider returns the same way.

A plain OAuth access token proves what an app can access. It says nothing about who the person is. Skipping ID-token verification, or assuming a valid access token proves identity, is a specific, common, and avoidable authentication bug. If your integration needs to know who is connecting, OIDC is the layer that answers that question — OAuth alone was never designed to.

Paragon vs. Merge vs. Auth0 vs. WorkOS vs. building it yourself

Paragon is a strong fit for customer-facing products that need per-user auth and a managed connector layer on top of it, in one system — not a claim to the best token-isolation primitive standing alone. Current as of August 2026 — vendor specifics change; verify against each provider's own documentation before citing.


Paragon

Merge

Auth0 Token Vault (w/ Organizations)

WorkOS Pipes

Build it yourself

Authentication model supported

Per-end-user, isolated per (user, provider)

Per-end-user via Merge Link; account_token scoped to that end user/integration pair

Per-user, org-scoped token records

Org-scoped per (provider, userId, organizationId)

Whatever you build — most teams start with a shared service account and re-architect later

Embedded connection UI

Connect Portal

Merge Link (drop-in UI component)

None published

Pipes Widget (white-labeled)

None — you build the connection screen per provider

Token storage & isolation

Encrypted at rest in an isolated vault, keys and encrypted values stored separately, Paragon-managed

Customer stores the account_token itself; Merge doesn't hold it on the customer's behalf. No published cross-tenant isolation architecture

Per-user, organization-scoped token records; isolation "guaranteed by architecture, not by policy" (Auth0's framing)

Encrypted vault storage, org-scoped

Whatever you build — see "Paragon's Approach to Securely Storing Credentials" above for the bar to clear

Refresh/rotation handling

Managed — background refresh, race-condition handling, and forced-de-authorization detection absorbed

Not published for normal operation. On a failed connection, status moves to RELINK_NEEDED and the end user re-authorizes through Merge Link — the same re-auth-on-failure path Paragon and most vendors use

"Full token lifecycle management (refresh, rotation, revocation)" (Auth0's own phrasing)

Automatic token refresh and expiry handling

You own it — see the refresh, race-condition, and de-authorization sections above

What you still own

Your product's UX and the connector actions specific to your own workflows

Storing the account_token; detecting RELINK_NEEDED and prompting the end user to relink; building and hosting the frontend that renders Merge Link

The embedded integration layer, connectors, actions, sync, on top of the token primitive

The embedded integration layer on top of the token/auth primitive

Everything: auth, storage, refresh, and connector maintenance over time

Best fit

Customer-facing products that need per-user auth and a managed connector layer together

Teams already on Merge's unified API for data sync who want the same per-end-user auth model without a separate connector/UI layer

Teams already standardized on Auth0 for identity, adding the token-isolation primitive without a connector layer on top

Teams that want a per-org token vault and a connection widget, building the connector/action layer separately

Teams with a small, stable set of providers and dedicated engineering capacity to own it long-term

Auth0 Token Vault and WorkOS Pipes both solve a version of the same problem the taxonomy above is built around: token isolation per end user. Paragon isn't the only vendor with that primitive, and doesn't claim to be — the difference is what sits on top of it. Auth0 and WorkOS ship the identity/token layer; we pair that same kind of per-user isolation with Connect Portal and the managed connectors built on top of it, one integrated system your team doesn't have to assemble itself.

Closing Thoughts

Auth is just the first step in building any native integration for your application, but it's incredibly complex to get right, and every other integration you build depends on getting it right first.

That's why we built fully managed authentication for our customers: we took on the burden of auth, the per-user identity model above included, and your team gets to spend its time on what's unique to your product.

To see how we handle auth across hundreds of integrations, book time with our team.

If you do decide your team should own these challenges instead, the sections above are a working blueprint for building your own authentication infrastructure for integrations. And if the harder question is whether to build a multi-provider OAuth layer yourself at all, see our guide to build vs. buy authentication for integrations.

FAQ

What does it take to authenticate to my users' integrations? Start with the identity model: your app, a shared service account, or per-end-user, each one obligating different storage, isolation, and revocation work. Paragon Auth already runs the per-end-user model, with Connect Portal as the connection screen and every credential encrypted and isolated in a Paragon-managed vault. (Provider-specific refresh policies and rotation are a separate problem — see our guide to OAuth token refresh and expiry at scale.)

Why is authenticating to customer-facing integrations harder than internal, system-to-system auth? An internal integration failing is something your own team quietly notices and fixes. A customer-facing one fails in front of the customer first, and it turns into a support conversation before it ever reaches your error logs. It also means one OAuth app registration per provider serves every customer on that provider, so a single policy change from the provider affects all of them at once.

What's the difference between OAuth and OIDC, and why does it matter for integrations? OAuth authorizes what an app can access; it was never designed to tell you who the user is. OIDC adds an identity layer on top, an ID Token, a UserInfo endpoint, and standard claims, specifically to answer that question. Treating a valid OAuth access token as proof of identity, without verifying an ID token, is a common and avoidable mistake.

Why does multi-tenant authentication get harder as you add customers? Multi-tenant authentication turns one integration definition into a separate live credential per customer per provider, an N×M problem that grows faster than your customer count alone suggests. Paragon isolates each of those credentials per user, so a failure or a compromise in one doesn't touch another's, which is the piece most teams underestimate until they've already scaled past it.

How do the three identity models differ: your app, a shared service account, and per-end-user? Authenticating as your app means using one set of credentials your whole product owns—fine when no specific customer is involved. A shared service account uses one login per customer, so you can't tell which of their users did what. Per-end-user authenticates each person individually, which most customer-facing products need — Paragon's Connect Portal handles that flow, with every user's credential encrypted and isolated on its own.

Related

  • Multi Tenant Auth For Customer Facing Integrations

  • Oauth Token Refresh Expiry At Scale

Related

TABLE OF CONTENTS
    Table of contents will appear here.
Ship native integrations 7x faster with Paragon

Ready to get started?

Join hundreds of SaaS companies that are scaling their integration roadmaps with Paragon

Ready to get started?

Join hundreds of SaaS companies that are scaling their integration roadmaps with Paragon

Ready to get started?

Join hundreds of SaaS companies that are scaling their integration roadmaps with Paragon

Ready to get started?

Join hundreds of SaaS companies that are scaling their integration roadmaps with Paragon