POST
OAuth 2 is not a login button
Jul 20267 MIN READ
There is a moment in every web developer's life when they add "Sign in with Google" to their app and think they have done authentication. The button appears, the user clicks, the user comes back, and somewhere in the code there is an object with an email address and a name. The feature works. The security is a house of cards, and the developer does not know it, because the card that was propped up wrong is the one they never looked at.
OAuth 2 is a delegation protocol, not a login protocol. It was designed so that one service can give another service limited access to its resources, not so that websites can identify their users. The login button is a use case built on top of it, and the use case works, and the security failures come from treating the protocol as a black box. The interesting and dangerous parts, the tokens, the scopes, the redirect URI, the flow itself, are where the protocol's design and your assumptions disagree.
The players
OAuth 2 has four roles, and the names are worth learning because the security failures come from confusing them. The resource owner is the user. The client is your application. The authorization server is Google, or GitHub, or whoever holds the accounts. The resource server is the API that serves the user's data, and in the modern version it is usually the same company as the authorization server.
The flow that the login button uses is the authorization code flow, and it exists to solve a specific problem: the client should never see the user's password, and it should never need to. The user proves themselves to the authorization server, the server gives the client a code, and the client exchanges the code for a token in a back channel. The password stays with the authorization server. The client gets a credential it can use to act on the user's behalf, within limits.
The redirect URI is the attack surface
The most common OAuth vulnerability in the wild is the redirect URI, and it is the part nobody looks at. The flow works because the authorization server sends the user, and the code, back to the client at a specific URI that the client registered. If the client accepts a broader set of URIs than it registered, or if it uses the redirect URI from the request without checking it, an attacker can craft a link that sends the code somewhere it controls.
The fix is discipline on both sides. The client should register the exact redirect URIs, and validate them exactly, including the scheme, the host, and the path, no wildcards. The authorization server should reject unregistered URIs. The teams that skip this because "it is just an internal tool" are the teams whose internal tool gets their accounts taken over, because the user's access token is exactly as valuable as the user.
The code exchange and the state parameter
The other classic failure is the missing state parameter. The state is a random value that the client generates, sends with the authorization request, and verifies when the user comes back. Its job is to bind the response to the request, preventing a class of attack where the attacker tricks the user into completing a flow the attacker started. The state check is one line, and the teams that skip it because the flow worked anyway are the teams that will not know they are vulnerable until someone exploits it.
The code exchange has its own rule: the code is exchanged in a back channel, from the server to the authorization server, with the client secret. The code must never appear in the browser, and the exchange must happen server side. A team that exchanges the code in the browser is a team that has shipped the client secret to the client, and a secret in the client is not a secret.
PKCE is not optional anymore
The native mobile app has an OAuth problem that the web app does not: there is no place to keep a client secret. The public client, the mobile app or the single page app, cannot protect a secret, because the code runs on the user's device. The answer is PKCE, the proof key for code exchange, and it is the difference between a mobile OAuth flow that is safe and one that is a hijack waiting to happen.
PKCE works by adding a random secret that the client generates, called the verifier, and sends a hash of it, the challenge, with the authorization request. When the client exchanges the code, it sends the verifier, and the authorization server checks it against the challenge. An attacker who steals the code cannot exchange it without the verifier, and the verifier was never transmitted in the open. The advice that used to be "use PKCE for mobile" is now the baseline: use PKCE for every public client, and use it for confidential clients too, because it is cheap and it removes a whole class of attack.
The token is not the identity
Here is the conceptual mistake that causes the most damage: the access token is a credential, not an identity. When your app receives a token from Google, it proves that Google vouches for the user, within the scope of the token. It does not prove that the email address on the token is still valid, that the account has not been suspended, or that the user has any relationship with your app beyond what the token says.
The token also expires, and the refresh token is the mechanism for getting a new one. The refresh token is a long lived credential, and it is the one that gets leaked in the bad integrations: stored in local storage, logged in the debug output, sent in the query string. The refresh token should live server side, in a secure store, and it should be rotated, revoked on logout, and never exposed to the browser.
The identity question deserves one more sentence: if your app needs to know who the user is, not just that someone has a token, the token alone is not enough. The userinfo endpoint, or the identity token in the OpenID Connect extension, is how you get the identity claims, and the claims should be validated like any other input, because the token can be forged if the validation is weak.
The scope is the permission
The scope is the list of things the client is allowed to do, and it is the part that developers treat as a formality. Ask for the minimum: the email address if that is all you need, not the full profile, not the calendar, not the contacts. The scope is the blast radius of a stolen token, and the scope that asks for everything is the scope that turns a small leak into a data breach.
The least privilege discipline applies to the token too. The token your app keeps should have the narrowest scope that does the job. The token your app does not need should not be requested, because every scope you request is a scope you are responsible for protecting.
The honest version
OAuth 2 is a good protocol and a terrible thing to wing. The login button is the tip of a system of checks and balances, and every check exists because someone was attacked. The redirect URI validation, the state parameter, the back channel exchange, the PKCE verifier, the scope discipline, the refresh token protection, they are not ceremony. They are the protocol's answer to the question "what happens when the attacker controls part of the flow", and the answer is the parts of the flow you control.
The teams that ship OAuth with the full discipline sleep fine. The teams that ship the login button and stop do not know they are exposed, which is the worst kind of exposure, because it is the kind you cannot fix until it is used against you.
The password is the thing you never see. The tokens are the thing you must protect. The redirect is the thing you must validate. The scope is the thing you must minimize. Do all four, and the login button can be the boring feature it is supposed to be.