Resources / Tutorial
The OAuth2 redirect dance: why the authorization-code flow works the way it does
Why providers make you register an app, configure a redirect URI, and trade a code for tokens — and the end-to-end setup.
The first time I wired up an integration that needed a real user's permission — QuickBooks, in my case — I remember thinking the setup was absurd. Register an app? Configure a redirect URI? Get bounced to their login page and back, then fish a code out of the URL and trade it for the token I actually wanted? With an API key I just put a string in a header and went. This felt like a scavenger hunt.
Then it clicked why every serious provider — QuickBooks, Microsoft, Google, all of them — makes you do the same dance. Once you see what each step is protecting against, the flow stops feeling like bureaucracy and starts feeling inevitable. In API keys vs OAuth2 I covered the machine-to-machine side and deliberately deferred this one. This is that article: the authorization-code flow, why the redirect exists, and the end-to-end setup.
The problem the redirect solves
Say your app needs to read a customer's QuickBooks data. The worst possible design is the obvious one: ask the user for their QuickBooks email and password and log in as them. Now you're holding credentials that unlock their entire account, forever, and they have no way to revoke just your access without changing their password everywhere.
The redirect dance exists so that your app never sees the user's password. The user signs in with the provider directly, on the provider's own domain, and the provider hands your app a limited, revocable grant. Every weird-looking step serves that goal:
- The redirect to the provider: the login form is theirs, not yours. The password goes from the user to QuickBooks. You're not in the middle.
- The redirect back with a
code: the provider can't just hand tokens to whatever page the browser lands on. Thecodeis a short-lived, one-time claim ticket — useless on its own. - The code-for-token exchange: your server swaps the code for tokens in a back-channel call that includes your client secret. So the tokens only go to the app that registered, not to whoever managed to grab the code off the wire.
That's the whole trick. A claim ticket through the front door (the browser), tokens through the back door (server to server), password never leaving the provider's site.
Step 1: register the app
Every provider has a developer portal where you create an app registration. Whatever they call it, you walk out with the same three things:
- a client id — public, identifies your app
- a client secret — private, proves it's really your app calling
- a redirect URI you configured — where the provider is allowed to send users back
The redirect URI matters more than it looks. The provider will only redirect to URIs on that registered list, exact match — scheme, host, port, path. That's a security control: even if someone tricks a user into authorizing with your client id, the code still only ever gets sent to your endpoint. It's also the source of the most common error in this whole flow. If you see redirect_uri_mismatch, the URI your code sends and the one in the portal differ somewhere, and "somewhere" is usually a trailing slash, an http vs https, or a port.
For local dev, register https://localhost:5001/callback (or whatever your dev URL is) alongside the production one. Don't try to share one.
Step 2: send the user to the provider
When the user clicks "Connect QuickBooks" (or "Sign in with Microsoft"), you build an authorization URL and redirect the browser to it:
https://auth.provider.com/oauth2/authorize
?client_id=your-client-id
&redirect_uri=https://yourapp.com/callback
&response_type=code
&scope=accounting.read
&state=f3a9c1d27e
Two parameters worth a closer look:
scope is you asking for the minimum you need. Request read access if you only read. Users see the scope list on the consent screen, and "this app wants to read your reports" gets approved a lot more readily than a wall of permissions.
state is a random value you generate and stash in the user's session before redirecting. When the provider sends the user back, it echoes state back to you, and you check that it matches what you stashed. If it doesn't, someone is trying to feed your callback a code you never asked for — a CSRF attack — and you stop. It's one random string and one comparison. Don't skip it.
The user lands on the provider's login page, signs in (with a password you never see), reviews what your app is asking for, and clicks approve.
Step 3: catch the redirect, pull out the code
The provider sends the browser back to your registered redirect URI:
https://yourapp.com/callback?code=AB12xy...&state=f3a9c1d27e
Your callback endpoint checks state, then grabs the code. In ASP.NET Core:
[HttpGet("callback")]
public async Task<IActionResult> Callback(string code, string state)
{
var expected = HttpContext.Session.GetString("oauth_state");
if (state != expected)
return BadRequest("state mismatch"); // not our request — bail
var tokens = await ExchangeCodeAsync(code);
// store tokens, mark the connection live, send the user somewhere useful
return RedirectToAction("Connected");
}
Move fast here — the code typically expires in a minute or two and is single-use. It's a claim ticket, not the prize.
Step 4: swap the code for tokens
This is the back-channel call: your server, the provider's token endpoint, your client secret. The browser is not involved.
async Task<TokenResponse> ExchangeCodeAsync(string code)
{
var form = new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "authorization_code",
["code"] = code,
["redirect_uri"] = "https://yourapp.com/callback", // must match again
["client_id"] = Config.ClientId,
["client_secret"] = Config.ClientSecret
});
var res = await _http.PostAsync("https://auth.provider.com/oauth2/token", form);
res.EnsureSuccessStatusCode();
return (await res.Content.ReadFromJsonAsync<TokenResponse>())!;
}
Back comes the payload you were after:
{
"access_token": "eyJhbGciOi...",
"refresh_token": "8xLOxBtZp8...",
"token_type": "Bearer",
"expires_in": 3600
}
The access token is what you send on API calls, same as any bearer token. It dies in about an hour. The refresh token is the long-lived piece: when the access token expires, you post the refresh token to the same token endpoint (grant_type=refresh_token) and get a fresh pair — no user, no browser, no redirect. That's how your integration keeps working at 3 AM without anyone clicking "Connect" again.
Which makes the refresh token the crown jewel. It's effectively standing access to the user's account until they revoke it. Store it encrypted, server-side, keyed to the user. It never belongs in the browser, in logs, or in source control — the same rules as any secret, with less forgiveness.
Where people get burned
Redirect URI mismatch. Exact match means exact. Compare scheme, host, port, path, trailing slash, character by character, between your code and the portal.
Skipping state. Everything works fine without it, which is exactly why it gets skipped — until someone exploits your callback. It's two lines.
Tokens in the browser. The exchange happens server-side so that tokens stay server-side. If your design has the access or refresh token living in JavaScript, rethink the design. (If you're building a SPA or mobile app with no server to hold a secret, that's what PKCE is for — same flow, with a one-time challenge standing in for the client secret.)
No refresh handling. The integration works great for an hour, then 401s forever. Cache the access token, watch the expiry, refresh early — same pattern as the token cache in the first article. And handle the refresh failing, because users revoke access. That's not an error to retry; that's "send the user back through the connect flow."
Reusing the code. The code is single-use. If your callback can fire twice (a refresh, a double-click), the second exchange fails. Make the callback idempotent: if you already have live tokens for this state, don't exchange again.
The shape of the thing
Strip the vendor branding away and every provider's "connect your account" feature is this same five-step machine: register, redirect out, redirect back with a claim ticket, exchange it server-side, refresh quietly forever after. Once you've built it against one provider, the second one is mostly reading their portal docs to find where the names differ. The dance only looks complicated until you realize each step is the answer to "how do we do this without your app ever touching a password" — and then it's just the steps.