How to do OAuth right?
Published

How to do OAuth right?
OAuth is one of those technologies that feels simple when you see the demo, then quietly turns into a security and UX minefield when you ship it to production. “Login with Google” works, tokens appear, API calls succeed, and everyone moves on. Until you get a bug report about random logouts, a security review that flags “token leakage,” or a customer asking why your app can access their entire inbox.
Doing OAuth right is less about memorizing the spec and more about making a few consistently good choices: the right grant for the right client, the right place to store tokens, the right scopes, the right validation, and the right operational hygiene. This guide walks through those choices with practical examples so you can build a secure, reliable OAuth 2.0 integration that passes audits and holds up under real traffic.
Start with the mental model: OAuth is delegation, not authentication
OAuth 2.0 is a framework for delegated authorization. It lets a user grant an application limited access to a resource server, without giving the application the user’s password. That last part matters: OAuth is not fundamentally “login.” It can be used in login flows, but you need to be deliberate about what you are actually proving.
A helpful way to separate concerns:
- OAuth 2.0 answers: “Can this client call this API with these permissions?”
- OpenID Connect (OIDC) answers: “Who is the user, and how can the client verify their identity?”
If your goal is user login, prefer OIDC on top of OAuth. You can still do OAuth without OIDC, but you will end up reinventing identity verification, and it often ends badly.
Key terms you should be fluent in (because they drive implementation decisions):
- Resource owner: the user granting access.
- Client: your app (web, mobile, SPA, service).
- Authorization server: the identity provider (IdP) issuing tokens.
- Resource server: the API you call with the access token.
- Access token: short-lived credential for API access.
- Refresh token: long-lived credential to obtain new access tokens.
- Scopes: the permissions being requested.
The rest of “how to do OAuth right” is picking the correct flow and treating tokens like the sensitive credentials they are.
Choose the right OAuth flow for your app (and avoid the wrong ones)
There are multiple OAuth grant types. Not all are appropriate in 2026. Choosing correctly is the first major fork.
Authorization Code with PKCE (use this for almost everything)
For browser-based apps (SPAs), mobile apps, desktop apps, and traditional server-rendered web apps, the safest default is:
- Authorization Code flow
- plus PKCE (Proof Key for Code Exchange)
PKCE prevents authorization code interception and is now a baseline recommendation even for confidential clients.
Use Authorization Code + PKCE when:
- You have a browser redirect-based login experience.
- You can handle redirects and callbacks.
- You want strong protection against code interception.
Client Credentials (machine-to-machine)
If there is no user and your service needs to call another service:
- Use Client Credentials.
- Do not request user scopes.
- Avoid using this for “admin jobs that run as a user.” Model those explicitly if needed.
Avoid Implicit flow
The Implicit grant used to be common for SPAs because it avoided a back-channel token exchange. It also leaks tokens more easily through browser history, referrers, and front-channel delivery.
In modern OAuth guidance, you should treat Implicit as legacy and avoid it unless you are constrained by an outdated IdP.
Avoid Resource Owner Password Credentials (ROPC)
If you see “the app collects the user’s password and exchanges it for a token,” that is ROPC. It breaks single sign-on, bypasses MFA, and increases credential theft risk. Use redirects to the authorization server instead.
Decide if you are a public or confidential client
This affects whether you can keep a secret.
- Confidential client: server-side app where you can keep client secrets safe.
- Public client: SPA or mobile app where secrets can be extracted.
A common “OAuth done wrong” scenario is embedding a client secret in a mobile app or SPA. Treat those as public clients and use PKCE.
Build the flow: a practical, correct Authorization Code + PKCE example
Let’s anchor the theory with a concrete flow. This is a common case: a web app or SPA that needs to call an API on behalf of the user.
Step 1: Generate PKCE values
You generate a code_verifier (random) and a code_challenge (derived).
code_verifier: high-entropy string stored temporarily on the client.code_challenge: base64url(SHA256(code_verifier)) sent to the authorization server.
Example (Node.js):
import crypto from "crypto";
function base64url(input) {
return input.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/g, "");
}
export function generatePkcePair() {
const codeVerifier = base64url(crypto.randomBytes(32));
const codeChallenge = base64url(crypto.createHash("sha256").update(codeVerifier).digest());
return { codeVerifier, codeChallenge, method: "S256" };
}Store code_verifier in memory or a secure, short-lived location. If you store it in localStorage, you increase the blast radius of XSS. Prefer memory, or an httpOnly session on the server if you are doing a server-assisted flow.
Step 2: Redirect to the authorization endpoint
Send the user to the IdP with:
response_type=codeclient_idredirect_uriscopestatecode_challengeandcode_challenge_method=S256nonce(if using OIDC and expecting an ID token)
Example authorization URL (OIDC-style scopes):
GET https://idp.example.com/oauth2/authorize?
response_type=code&
client_id=your_client_id&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
scope=openid%20profile%20email%20calendar.read&
state=RANDOM_CSRF_TOKEN&
nonce=RANDOM_NONCE&
code_challenge=CODE_CHALLENGE&
code_challenge_method=S256State is not optional. It is your CSRF protection for the redirect flow. Store it and verify it on callback.
Step 3: Handle the callback and validate state
On https://app.example.com/callback?code=...&state=...:
- Verify
statematches what you issued. - Extract the
code. - Exchange the code for tokens at the token endpoint.
Step 4: Exchange code for tokens (back-channel)
Example token request:
POST https://idp.example.com/oauth2/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
client_id=your_client_id&
redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback&
code=AUTH_CODE&
code_verifier=ORIGINAL_CODE_VERIFIERIf you are a confidential client, you may also authenticate with a client secret or private key JWT, depending on the provider.
Step 5: Validate tokens correctly
If you use OIDC, you often receive:
id_token(JWT): identity assertion.access_token: API access.- maybe
refresh_token: long-lived token.
Basic validation checklist for a JWT (ID token or JWT access token):
- Verify signature against the correct JWKS key.
- Verify
iss(issuer) is expected. - Verify
aud(audience) matches your client. - Verify token is not expired (
exp) and not-before (nbf) if present. - Verify
nonce(OIDC) matches the one you sent. - Verify
azp(authorized party) when relevant.
Many integrations fail because they only “decode” the JWT and skip signature verification. That is not validation.
Example (pseudo-code):
const token = parseJwt(idToken);
assert(token.iss === EXPECTED_ISSUER);
assert(token.aud === CLIENT_ID);
assert(token.exp * 1000 > Date.now());
assert(token.nonce === storedNonce);
verifySignature(idToken, jwksForIssuer);Step 6: Use the access token to call APIs
Send the access token in the Authorization header:
GET https://api.example.com/v1/events
Authorization: Bearer ACCESS_TOKENDo not put tokens in query strings. URLs leak through logs, browser history, referrers, and analytics.
Token storage, refresh tokens, and session strategy (where most apps go wrong)
OAuth implementations often “work” but create quiet security debt. Token handling is where that debt accumulates.
Prefer short-lived access tokens
Short-lived access tokens reduce the impact of token theft. Many providers default to 1 hour or less. For high-risk systems, even shorter can be appropriate.
A practical approach:
- Access token lifetime: 5 to 60 minutes.
- Refresh token: long-lived, rotation enabled, revocable.
Use refresh token rotation when available
Rotation means every refresh returns a new refresh token and invalidates the old one. If an attacker steals a refresh token and tries to use it later, you detect reuse and can revoke the entire session.
If your provider supports it, enable:
- Refresh token rotation
- Reuse detection
- Sender-constrained tokens (mTLS or DPoP) if feasible
Store tokens in the right place
A secure default differs by app type.
Server-rendered web apps (best case)
Store tokens server-side and keep the browser holding only a session cookie.
- Browser: httpOnly, Secure, SameSite cookie referencing a server session.
- Server: encrypted token storage tied to the session.
SPAs
You often cannot fully avoid client-side exposure, but you can limit damage.
- Avoid
localStoragefor long-lived tokens if possible. - Prefer in-memory access token storage.
- Consider a backend-for-frontend (BFF) pattern where the server holds tokens and the SPA talks to your server with a session cookie.
Mobile apps
Use platform secure storage:
- iOS Keychain
- Android Keystore / EncryptedSharedPreferences
A simple, safe session pattern (BFF)
If you can run a small backend, the BFF model dramatically simplifies “OAuth done right” for SPAs:
- The SPA never sees access or refresh tokens.
- The backend performs the OAuth flow and stores tokens.
- The SPA uses a same-site session cookie to call your backend.
- The backend calls the third-party API.
This reduces XSS token theft risk and makes logout and revocation cleaner.
Handle logout like you mean it
“Logout” should consider:
- Your local session
- The refresh token at the IdP
- Single logout (if required)
At minimum:
- Clear the app session.
- Revoke refresh tokens (if provider offers revocation endpoint).
- Do not just delete the access token and call it done.
Scopes, consent, and least privilege without killing adoption
Scopes are both a security control and a product decision. Request too much and users abandon the flow. Request too little and you cannot deliver value.
Follow least privilege in a way users understand
Good scope hygiene:
- Request only what you need for the current feature.
- Prefer incremental authorization: ask for more scopes when the user enables a feature.
- Use provider-recommended scopes rather than broad wildcards.
A practical rollout pattern:
- Start with
openid profile email(OIDC identity). - Add one API scope tied to a visible feature.
- Expand only after users opt in.
Make consent screens predictable
Users trust what they can understand. Help them by:
- Aligning your app name and logo with what they expect.
- Using verified domains and correct redirect URIs.
- Writing clear justifications in your UI before redirecting.
Avoid scope creep in code
Enforce scope discipline with tests and configuration, not developer memory.
- Keep requested scopes in one config file.
- Add automated checks that prevent adding new scopes without review.
- Log granted scopes and compare them to what you asked for.
Example scope config:
{
"oidc_scopes": ["openid", "profile", "email"],
"api_scopes": {
"calendar": ["calendar.read"],
"contacts": ["contacts.read"]
}
}Security checklist: common OAuth vulnerabilities and how to prevent them
If you want to do OAuth right, you need to actively defend against a handful of recurring failure modes. Here is a practical checklist used in many security reviews.
Protect redirect URIs (no wildcards)
Misconfigured redirect URIs are a top source of token leakage.
Do:
- Register exact redirect URIs.
- Use HTTPS in production.
- Keep a tight allowlist.
Avoid:
- Wildcards like
https://example.com/* - Open redirect endpoints that forward to arbitrary locations
Always validate state and use nonce for OIDC
stateprevents CSRF and login mix-up attacks.noncebinds the ID token to the authentication request.
Implementation tips:
- Generate cryptographically random values.
- Store them server-side or in a secure session.
- Validate them exactly once.
Do not log tokens
Tokens often end up in:
- reverse proxy logs
- browser error telemetry
- application logs
- analytics tools
Mitigations:
- Redact Authorization headers in logs.
- Avoid query-string tokens entirely.
- Treat token payloads as sensitive data.
Use the right audience and verify it
A token for API A should not work on API B. That is what aud is for.
- Ensure your resource server validates
audandiss. - If you operate multiple APIs, use distinct audiences.
Prefer sender-constrained tokens when possible
Bearer tokens can be used by anyone who has them. Sender-constrained tokens reduce replay risk.
Options:
- mTLS: strong but operationally heavier.
- DPoP: proof-of-possession with a client key, increasingly supported.
If you cannot use these, compensate with:
- short-lived access tokens
- refresh token rotation
- anomaly detection
Handle multi-tenant and “login with X” edge cases
Common production issues include:
- Users with multiple accounts at the same provider.
- Users who revoke consent then re-authorize.
- Providers that change token formats or rotate signing keys.
Operational practices:
- Cache JWKS with expiration and handle key rotation gracefully.
- Treat provider outages as expected: retry token exchange carefully.
- Store provider user IDs (subject claims) rather than emails as stable identifiers when available.
Operational excellence: reliability and observability for OAuth integrations
OAuth failures are uniquely frustrating because the user gets bounced between domains, and debugging feels opaque. A little instrumentation pays off.
Measure the funnel
Track each step:
- Authorization redirect initiated
- Callback received
- State validated success/failure
- Token exchange success/failure (with error codes)
- API call success/failure (401, 403, 429)
This helps you spot:
- redirect URI mismatches after deploys
- mis-scoped requests causing 403s
- rate limiting and quota issues
Build actionable error handling
When OAuth fails, surface a message the user can act on.
Examples:
- If consent denied: “You cancelled access. To connect your calendar, try again and approve access.”
- If token expired and refresh fails: “Your connection expired. Reconnect to continue.”
- If scopes insufficient: “We need additional permission to create events. Approve the new permission to enable this feature.”
Plan for token revocation and provider policy changes
Providers update policies, especially around sensitive scopes. Keep your integration resilient by:
- supporting re-consent flows
- minimizing sensitive scopes
- keeping a human-readable “connected account status” page
- providing an easy “disconnect” button that revokes tokens
Conclusion: the simple rules that make OAuth “right”
If you want a reliable, secure answer to “How to do OAuth right?”, follow these rules consistently:
- Use Authorization Code with PKCE for user-facing clients, and Client Credentials for service-to-service.
- Treat OAuth as delegation, and use OpenID Connect when you need authentication.
- Validate everything that matters:
state,nonce, signatures, issuer, audience, expiration. - Store tokens safely: prefer server-side sessions or a BFF pattern, enable refresh rotation, and keep access tokens short-lived.
- Keep scopes minimal and request permissions incrementally.
- Lock down redirect URIs, never log tokens, and instrument the flow so failures are diagnosable.
If you are implementing or auditing an OAuth integration, take your current flow and compare it to the checklists above. Fix the flow choice first, then token storage, then validation, then scopes. That order delivers the biggest risk reduction quickly, and it will make your OAuth implementation feel boring in production, which is the highest compliment security can get.
Worth sharing?
Send this to someone who would find it useful.