Blog
Six OAuth 2.0 Anti-Patterns to Avoid
Dev Tips

Six OAuth 2.0 Anti-Patterns to Avoid

Have you implemented OAuth for your app? Did you follow the spec? All of it? Learn what can happen when you decide to get creative at the wrong time.
Mar 16, 2026
Taylor Reece
Taylor ReeceDeveloper Experience Engineer
Six OAuth 2.0 Anti-Patterns to Avoid

I like to joke that developers who add the OAuth 2.0 authentication code flow to their apps read the spec, implement 90% of it, and then simply wing the last 10%. I say it in jest, but it's not far from the truth.

After building over 200 connectors (84 of which use OAuth 2.0 authentication), we've seen dozens of deviations from the spec that you absolutely should not mimic when adding OAuth 2.0 to your app.

What is OAuth 2.0?

But, before we cover what OAuth 2.0 shouldn't be, let's see what it is. OAuth 2.0 is a standard that allows one app to access data in another app on your behalf.

If you've ever clicked a "Log in with Google" button or selected "Click here to connect your Outlook calendar", you've likely gone through an OAuth flow.

How should OAuth 2.0 auth code work?

Suppose you're logged in to Acme. Acme has an integration with Dropbox, and to enable it, you click a handy button labeled "Click here to link your Dropbox account." Clicking that button will bring you to Dropbox's Authorize URL,

1
https://www.dropbox.com/oauth2/authorize?client_id=abc-123&state=foo&...

Once you arrive at Dropbox, you'll be presented with a consent screen – a screen that says something like "Acme would like access to files in thus-and-such Dropbox folder. Cool?" You're familiar with these screens, I'm sure.

Once you select "Yeah, grant access to Acme," Dropbox sends you back to Acme's Callback URL,

1
https://oauth2.acme.com/callback?state=foo&code=bar1234

At this point, Acme has your "auth code" (the code=bar1234 bit). Acme can now use Dropbox's Token URL to exchange that code (along with a client_secret) for an access_token and sometimes a refresh_token.

From there, Acme can use your access token to do whatever you permitted it to do, all without ever handing Acme your Dropbox username and password.

Anti-patterns

The OAuth 2.0 authorization code spec is pretty straightforward: users are redirected through an Authorize URL, consent to some things, return to the calling app with an auth code, and the auth code is exchanged for an access token.

Straightforward, yes? Let's look at some common ways you can deviate from that simple pattern.

Token expiration in nanoseconds?

When an app completes the auth code exchange, it receives a payload that looks something like this:

123456
{
"access_token": "2YotnFZFEjr1zCsicMWpAA",
"token_type": "example",
"expires_in": 3600,
"refresh_token": "tGzv3JOkF0XG5Qx2TlKWIA"
}

The expires_in property is defined in section 4.2.2 of the OAuth 2.0 spec as "The lifetime in seconds of the access token.

Guess what happened when one of our customers got back an expires_in of 3,600,000,000,000? You guessed it. Our OAuth service set itself a reminder to refresh the token in 3.6 trillion seconds (or 114,077 years).

Obviously, that was not the app developer's intention. They were just pedantic and loved the precision of nanoseconds over seconds. However, doing it this way is an anti-pattern, and resulted in this lovely bit of code in our OAuth 2.0 service:

1234567891011
// Apparently some people feel the inexplicable need to use nanoseconds as
// the unit of measure for the expiration time. For now I think the best
// we can do is recognize a "stupidly large" value and assume that it's
// probably using a silly unit of measure, and "fix" it. We'll do this
// for nanoseconds for now, as guessing should be pretty safe.
if (intExpiresIn > 31_540_000_000) {
// If it looks like the number of seconds is more than 1000 years,
// it's quite likely that the unit of measure is something dumb
// like nanoseconds. So divide it by a billion...
intExpiresIn /= 1_000_000_000;
}

Token expiration in seconds vs at time

The spec clearly calls for expires_in – the number of seconds from now until the token expires. Despite that, some apps will try to save you some compute cycles by returning an expires_at timestamp instead. They give you something like "expires_at": "2026-03-05T15:36:41.304Z".

The spec doesn't mention an expires_at value, so it's something extra to account for. To compound the problem, there's no standardization of the date/time format for expires_at. While some apps give you a UTC timestamp in ISO 8601 format, others yield the number of seconds (nanoseconds?) since Unix Epoch, which means you need to do some amount of computation anyway, and leads to this part of our OAuth 2.0 service:

12345
// Handle the situation where we got an expires_at but no expires_in
if (!token.expires_in && token.expires_at) {
const expiresAt = new Date(token.expires_at);
intExpiresIn = Math.floor((expiresAt.getTime() - Date.now()) / 1000);
}

Auth code search parameters vs fragments

When you return to your original app's callback URL with a ?code=some-auth-code search parameter, the server that responds to the callback request exchanges the auth code for an access token. Section 4.1.2 of the spec is clear: this needs to be a URL query parameter.

Despite that, some apps opt to return a URI fragment (so, /callback#code=some-auth-code instead). Their justification is that fragments aren't stored in browser history (unlike search parameters), so your auth code is more secure. But auth codes are supposed to be invalidated shortly after issuance anyway – so not really a problem.

This creates a headache because fragments aren't readable by the callback server. Instead, the frontend needs to read the fragment and act on it.

To work around this, our customers have created an intermediary endpoint that includes some HTML/JS that converts the fragment into a search parameter, then redirects to our callback URL with the auth code as a search parameter.

Naming conventions

Have you ever gotten into an argument about snake_case vs camelCase vs kebab-case? Developers have strong opinions on this – especially if they have backgrounds in different tech stacks.

When you're creating an OAuth 2.0 service, though, your opinion on the topic doesn't matter – the spec requires snake_case.

Despite this, some apps use clientId or client-id instead of the correct client_id ; ditto for grantType or client-secret. Others make up an entirely new term, like appSecretKey for client_secret.

Please use snake_case. OAuth libraries don't map fields gracefully, and using anything other than what's specified requires special treatment for anyone wanting to integrate with your app.

Error responses

Section 5.2 of the OAuth spec outlines the expected error structure when an error occurs during token exchange. You need to provide an error that is an enum with a value like invalid_scope or unauthorized_client. If you need to provide more details, an optional error_description string can be included. An error may look like this:

1234
{
"error": "invalid_scope",
"error_description": "You asked for a scope of 'widgets.red' - do you mean 'widgets.read'?"
}

OAuth 2.0 libraries expect this format. Despite the clear spec, I've seen apps throw standards to the wind and return nested objects like:

123456
{
"error": {
"code": "invalid_request",
"description": "Invalid token"
}
}

This makes debugging tricky. A console.debug(error) yields an oh-so-helpful [object Object]!

Made-up OAuth 2.0 flows

A grant_type defines the OAuth 2.0 flavor you're using, and authorization_code is pretty standard for auth that requires user interaction. Other apps use client_credentials (machine-to-machine OAuth). Some older apps use the deprecated password OAuth flow.

Only one app on the internet uses the account_credentials flow – a made-up OAuth flow that's really client_credentials under the hood.

On to butterflies, now that we've handled snakes

If you're going to add OAuth 2.0 to your app, please read the spec and then follow it. Deviation from that doesn't make you a special butterfly, but it does make your app an edge case that anyone who integrates with it must explicitly handle.

A good practice to follow, once you have an OAuth 2.0 service in place, is to grab a few off-the-shelf OAuth clients, like simple-oauth2 for Node.js or oauth2-client for PHP, and verify that what you've built is compatible with the tools other apps will use to integrate with you. As a bonus, it's also the best way to ensure that your code won't show up in the next version of this post.

Get a Demo

Ready to ship integrations 10x faster?

Join teams from Fortune 500s to high-growth startups that have transformed integrations from a bottleneck into a growth driver.