How to Implement OAuth 2.0 Without Exposing Client Secrets

Secure OAuth 2.0 authorization flow protecting client credentials

A client secret protects an OAuth client only when the credential remains inside an environment controlled by the application owner. A backend server can usually provide that protection. JavaScript delivered to a browser, a mobile application package, a desktop executable, and a browser extension cannot.

Placing a client secret in one of these public applications does not make the application confidential. A user or attacker can inspect the source code, application bundle, source maps, network requests, memory, or compiled package and recover the credential.

The correct solution is not to hide the secret more effectively. It is to choose an OAuth architecture that does not depend on a secret the application cannot protect.

For public clients, the normal starting point is the Authorization Code flow with Proof Key for Code Exchange, commonly called PKCE. For applications that handle sensitive information or privileged operations, a backend-for-frontend can keep OAuth tokens outside the browser altogether.

Security scope This guide explains general OAuth architecture and defensive implementation practices. Provider requirements can differ. Applications handling payments, health information, administrative access, regulated data, or high-value accounts should receive a specialist security review before production launch.

Last reviewed: July 16, 2026. OAuth standards, identity-provider requirements, browser behavior, and library recommendations may change. Confirm the final implementation against your provider’s current documentation.

OAuth Is Not a Login Protocol by Itself

OAuth is primarily an authorization framework. It allows an application to obtain limited access to a protected resource without receiving the user’s password.

If the application needs to sign users in and establish their identity, use OpenID Connect on top of OAuth. OpenID Connect introduces concepts such as ID tokens, user identity claims, and the openid scope.

Requirement Use Purpose
Access an API OAuth 2.0 Obtain limited authorization to access protected resources
Sign a user in OpenID Connect Establish the authenticated user’s identity
Application-only access OAuth Client Credentials Allow one protected backend service to access another

Do not use an OAuth access token as proof of identity unless the provider explicitly defines that behavior. The client should not assume that every access token is a JWT or that its claims are intended for the client application.

Public Clients and Confidential Clients

OAuth distinguishes clients by their ability to protect credentials.

A confidential client runs in a controlled environment and can authenticate securely to the authorization server. A public client cannot reliably protect credentials distributed with the application.

Application Client Type Can Protect a Secret? Recommended Direction
Server-rendered web application Confidential Yes, when kept exclusively on the server Authorization Code flow with server-side token exchange and PKCE
Backend-for-frontend Confidential backend Yes, inside the backend Store tokens server-side and give the browser a protected session cookie
Browser SPA Public No Authorization Code with PKCE, or use a BFF for stronger isolation
Mobile application Public No Authorization Code with PKCE through the system browser
Desktop application Public No Authorization Code with PKCE and an approved redirect method
Browser extension Public No Provider-supported public-client flow with PKCE
Machine-to-machine backend Confidential Yes Client Credentials flow from protected server infrastructure
A client ID is not a secret The client ID identifies an OAuth client and normally appears in authorization requests. Security must not depend on hiding it. A static secret included in a public application can also be copied and must not be accepted as reliable proof of the application’s identity.

Choose the Architecture Before Writing OAuth Code

The most important decision is not which OAuth library to install. It is where tokens will be acquired, stored, and used.

Backend-for-Frontend

The backend completes OAuth, stores access and refresh tokens, and creates a cookie-based session. OAuth tokens are not directly exposed to browser JavaScript.

Token-Mediating Backend

The backend protects the refresh token but may provide short-lived access tokens to the browser. This reduces some risks but still exposes access tokens to the frontend.

Browser-Only SPA

The browser acts as a public OAuth client and handles tokens directly. Use Authorization Code with PKCE and design carefully for malicious JavaScript and token exposure.

Native Application

The mobile or desktop app uses PKCE and opens the authorization request in the user’s system browser or another approved external user-agent.

Which Architecture Should You Choose?

  • Prefer a backend-for-frontend for administrative dashboards, financial operations, private customer records, and other sensitive applications.
  • Consider a token-mediating backend when the browser must call resource servers directly but refresh tokens should remain on the server.
  • Use a browser-only public client when a backend is impractical and the risks are acceptable.
  • Use a native public client for installed mobile and desktop software.

A BFF reduces direct token exposure, but it does not make browser security irrelevant. Malicious JavaScript may still make authenticated requests through the user’s active session. Content Security Policy, dependency control, output encoding, CSRF protection, and secure application development remain necessary.

How PKCE Replaces Dependence on a Public Client Secret

PKCE uses a new temporary value for every authorization attempt.

The client generates a high-entropy code_verifier. It then calculates a code_challenge from that verifier and sends the challenge in the authorization request.

After authorization, the client receives a short-lived authorization code. To exchange that code for tokens, it must send the original verifier. A party that intercepts only the authorization code cannot complete the exchange without the verifier.

Authorization Code Flow with PKCE
Generate Verifier
Create S256 Challenge
Authorize User
Receive Code
Exchange Code + Verifier

PKCE does not turn a public client into a confidential client. It protects the authorization-code exchange. It does not prevent malicious JavaScript from using or stealing tokens after the application receives them.

Step 1: Register the Correct OAuth Client Type

Create separate client registrations for platforms with different security properties.

Do not use the same OAuth client registration for all of the following:

  • A confidential web server application.
  • A browser SPA.
  • An Android application.
  • An iOS application.
  • A desktop application.
  • Development, staging, and production environments.

Separate registrations allow each platform to have appropriate redirect URIs, consent settings, credential rules, token policies, and incident response.

Step 2: Register Exact Redirect URIs

The authorization server returns the user to a pre-registered redirect URI. Loose redirect validation may allow authorization codes or tokens to reach an attacker-controlled destination.

Register the complete URI whenever the platform permits it:

https://app.example.com/oauth/callback

Avoid broad wildcard patterns such as:

https://*.example.com/*
https://app.example.com/*
https://example.com/redirect?next=ANY_DESTINATION

Native desktop applications may use a loopback address with a dynamically selected port when supported by the provider, such as:

http://127.0.0.1:51004/oauth/callback

The redirect endpoint should process only expected OAuth responses. Do not turn it into a general-purpose open redirect.

Step 3: Generate a Verifier and an S256 Challenge

New implementations should use the S256 challenge method. The verifier must be generated with a cryptographically secure source of randomness and must never be logged or included in analytics.

The following browser example is illustrative. A maintained OAuth or OpenID Connect library is preferable for production systems.

function toBase64Url(bytes) {
  const binary = Array.from(bytes, byte =>
    String.fromCharCode(byte)
  ).join("");

  return btoa(binary)
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

async function createPkceValues() {
  const randomBytes = crypto.getRandomValues(new Uint8Array(32));
  const codeVerifier = toBase64Url(randomBytes);

  const verifierBytes = new TextEncoder().encode(codeVerifier);
  const digest = await crypto.subtle.digest("SHA-256", verifierBytes);
  const codeChallenge = toBase64Url(new Uint8Array(digest));

  return {
    codeVerifier,
    codeChallenge
  };
}

The verifier is temporary and belongs only to the current authorization transaction. Delete it after completing or abandoning the flow.

Step 4: Build the Authorization Request

A typical public-client request includes:

  • response_type=code
  • The public client_id
  • An exact redirect_uri
  • Minimal required scopes
  • The PKCE code_challenge
  • code_challenge_method=S256
  • Transaction protection such as an appropriate state value
  • An OpenID Connect nonce when required by the selected flow or library
GET https://identity.example.com/authorize
  ?response_type=code
  &client_id=public-web-client
  &redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
  &scope=openid%20profile%20api.read
  &code_challenge=GENERATED_CHALLENGE
  &code_challenge_method=S256
  &state=UNIQUE_TRANSACTION_VALUE
  &nonce=UNIQUE_OPENID_CONNECT_VALUE

The authorization request should open as a top-level browser navigation. Native applications should use the system browser or an approved external browser component rather than an embedded WebView.

PKCE, State, and Nonce Are Not Identical

Value Main Purpose Required Action
PKCE verifier Protect the authorization code against interception and injection Send the matching verifier during the token exchange
State Bind the response to the transaction and support CSRF protection Generate a unique value and compare it after redirect
OIDC nonce Bind an ID token to the authentication request and reduce replay risk Validate the matching claim when the flow uses it

Provider-enforced PKCE can also contribute to CSRF protection in properly configured flows. A practical implementation should use a trusted library and follow its transaction-handling requirements rather than removing state or nonce without understanding the consequences.

Step 5: Validate the Authorization Response

The redirect endpoint should not accept every request containing a parameter named code.

Before continuing:

  • Confirm that the response belongs to an authorization transaction initiated by the application.
  • Compare the returned state with the stored transaction value when state is used.
  • Reject missing, expired, duplicated, or unexpected transaction data.
  • Confirm the expected authorization-server issuer when the provider supports issuer identification.
  • Do not place authorization codes in logs, analytics events, support messages, or error-monitoring payloads.
  • Display a safe error when the provider returns an OAuth error response.

Authorization codes should be short-lived and used only once. Do not store them as long-term application data.

Step 6: Exchange the Code Without a Client Secret

A public client sends its authorization code and the original verifier to the token endpoint. It does not pretend to authenticate with a static secret.

POST /token HTTP/1.1
Host: identity.example.com
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
&client_id=public-web-client
&code=RETURNED_AUTHORIZATION_CODE
&redirect_uri=https%3A%2F%2Fapp.example.com%2Foauth%2Fcallback
&code_verifier=ORIGINAL_CODE_VERIFIER

The authorization server recalculates the challenge from the submitted verifier. If it does not match the challenge from the initial request, the exchange must fail.

Browser-only applications can perform this request only when the provider intentionally permits the public browser client to access its token endpoint, normally through an appropriate CORS policy.

Do not create your own token proxy carelessly A generic backend endpoint that accepts arbitrary token URLs or resource-server destinations may introduce server-side request forgery, token disclosure, and confused-deputy problems. A BFF should use an explicit allowlist of approved identity and API hosts.

Step 7: Validate Tokens in the Correct Component

The resource server, normally the API, is responsible for deciding whether an access token authorizes a request.

Depending on the provider and token format, this may require:

  • Validating a cryptographic signature using trusted provider keys.
  • Confirming the issuer.
  • Confirming the intended audience or resource.
  • Checking expiration and other time-related conditions.
  • Checking required scopes or authorization details.
  • Using the provider’s token introspection endpoint for opaque tokens.
  • Rejecting tokens created for another API, client, tenant, or environment.

Do not assume that decoding a JWT means it is valid. Decoding reveals its contents; validation verifies whether it can be trusted.

If OpenID Connect is used, the client must separately validate the ID token according to the provider and library requirements. An ID token is intended for the client and should not normally be sent to an API as an access token.

Worked Example: Sensitive Dashboard Using a BFF

Consider a business dashboard that displays invoices, customer records, and account administration controls. Exposing refresh tokens to JavaScript would create unnecessary risk.

A backend-for-frontend can use this structure:

BFF OAuth Architecture
Browser
BFF Login Endpoint
Authorization Server
BFF Stores Tokens
Protected API
  1. The browser requests the BFF login endpoint.
  2. The BFF creates the authorization transaction and redirects the browser to the identity provider.
  3. The identity provider redirects the authorization code back to the BFF.
  4. The BFF exchanges the code from its protected server environment.
  5. The BFF stores access and refresh tokens in a server-side session.
  6. The browser receives only an opaque session cookie.
  7. The browser calls the BFF, and the BFF calls the approved API with the access token.
See also  Resolving CORS Errors in Complex Single-Page Applications

Cookie Requirements

The session cookie should normally use:

  • Secure so it is sent only over HTTPS.
  • HttpOnly so normal JavaScript cannot read it.
  • An appropriate SameSite policy.
  • A restricted domain and path.
  • A limited lifetime aligned with the server session.
  • Rotation after authentication and other sensitive transitions.

Cookie-based sessions also require CSRF protection. Depending on the architecture, this may include SameSite controls, CSRF tokens, Origin or Referer validation, and restrictions on accepted content types and methods.

Worked Example: Browser SPA Using PKCE Directly

Consider a lower-risk SPA that reads a user’s project list from an API and cannot use a backend.

A safer implementation would:

  1. Register the SPA as a public client.
  2. Register its exact HTTPS redirect URI.
  3. Generate new PKCE and transaction values for each authorization attempt.
  4. Use Authorization Code with S256.
  5. Request only the scopes needed to read projects.
  6. Exchange the code without a client secret.
  7. Keep access tokens short-lived.
  8. Avoid persistent refresh tokens unless the provider implements the required protection.
  9. Apply strict frontend security and dependency controls.

This approach avoids embedding a client secret, but the SPA still executes in a browser environment. If malicious JavaScript runs in the application’s origin, it may access tokens, call APIs as the user, or initiate new authorization flows.

Where Should Tokens Be Stored?

No browser storage choice completely solves the risk of malicious same-origin JavaScript. Storage changes the exposure and persistence of a token, but application security remains essential.

Location Benefit Risk or Limitation Typical Fit
BFF server-side session OAuth tokens are not directly exposed to browser JavaScript Requires backend security, session management, and CSRF defenses Sensitive browser applications
Browser memory Tokens disappear when the execution context ends Tokens remain usable by malicious code in the active context and do not survive reloads Short-lived browser sessions
Web Worker or Service Worker Can isolate some token operations from the main application context Does not prevent malicious code from abusing the active session or starting a new flow Advanced browser architectures
localStorage Simple and persistent across page reloads Readable by same-origin JavaScript and exposed for a longer period Avoid for high-value or long-lived tokens when safer designs are available
Native secure storage Uses operating-system facilities designed for protected application data Security depends on the platform, device state, and implementation Mobile and desktop applications
Backend secret manager Central access control, auditing, and credential rotation The server and deployment pipeline must still be secured Confidential clients and backend credentials

Refresh Tokens Require Additional Protection

Refresh tokens can obtain new access tokens and may remain useful longer than an individual access token. Their exposure can therefore create a longer-lived compromise.

When an authorization server issues refresh tokens to public clients, it should detect replay through one of these protections:

  • Refresh token rotation: every successful refresh returns a new refresh token and invalidates the previous one.
  • Sender-constrained refresh tokens: the token is cryptographically bound to a particular client instance.

The authorization server should also apply appropriate expiration, revocation, scope, and resource restrictions. Confirm whether your provider supports these protections before requesting offline access from a public client.

Rotation reduces risk but does not solve every browser compromise. Malicious JavaScript that remains active may continuously capture the latest token or initiate a new authorization flow.

Optional Advanced Protection with DPoP

Demonstrating Proof of Possession, or DPoP, can bind an access token to a cryptographic key controlled by the client. Unlike a normal bearer token, copying the token alone is not sufficient to use it.

DPoP may reduce the impact of simple token exfiltration when the authorization server and resource server support it correctly. However, it does not eliminate malicious-script risk. Code running inside the application may still use the key through the legitimate browser context or start a new flow with an attacker-controlled key.

Treat DPoP as an additional layer, not as a replacement for PKCE, XSS prevention, limited scopes, short lifetimes, and appropriate architecture.

Protect Client Secrets on a Backend

A confidential client may use a secret or another supported client-authentication method, but the credential must remain inside protected server infrastructure.

Do not place backend credentials in:

  • Frontend JavaScript or bundled environment variables.
  • Mobile or desktop application packages.
  • Public Git repositories.
  • Docker images distributed to untrusted environments.
  • CI/CD logs.
  • Error messages or monitoring events.
  • Support tickets, chat channels, or shared documents.
  • Example code published in documentation.

Use a secret-management service or appropriately protected server configuration. Restrict who and what can read the credential, audit access, and rotate it after suspected exposure.

Development, staging, and production should use separate OAuth clients and separate credentials.

Frontend Security Still Matters

Removing the client secret does not make a SPA safe from cross-site scripting. Malicious JavaScript running in the application’s origin may act with the same browser privileges as the legitimate application.

Important defenses include:

  • Context-appropriate output encoding.
  • A restrictive Content Security Policy.
  • Avoiding unsafe HTML insertion.
  • Reviewing and minimizing third-party scripts.
  • Using Subresource Integrity where applicable.
  • Keeping frameworks and dependencies updated.
  • Protecting build and deployment pipelines.
  • Restricting token scope and lifetime.
  • Avoiding sensitive data in browser-accessible logs and analytics.

CORS is not an authorization mechanism. It controls which browser origins may read certain cross-origin responses, but APIs must still validate access tokens and enforce authorization on every protected operation.

Common OAuth Mistakes

Mistake Possible Result Safer Direction
Putting a secret in JavaScript Anyone can copy and reuse the credential Register a public client and use PKCE, or move OAuth to a backend
Using the Implicit flow Tokens may be exposed through browser redirects and other leakage paths Use Authorization Code with PKCE
Using PKCE with plain challenge The verifier may be exposed through the initial request Use the S256 challenge method
Allowing wildcard redirects Authorization responses may reach an unintended destination Register and compare exact redirect URIs
Using embedded WebViews for native login The application may inspect credentials and page content Use the system browser or an approved external user-agent
Requesting every scope A stolen token receives unnecessary power Request only the permissions required by the active feature
Logging codes or tokens Credentials may spread to monitoring and support systems Redact OAuth credentials from logs and telemetry
Decoding a JWT without validating it Forged or wrongly issued tokens may be trusted Validate signature, issuer, audience, expiration, and authorization
Using the Password Grant The client handles user credentials and bypasses safer authentication features Use a redirect-based Authorization Code flow

Production Readiness Checklist

Before launching, confirm:
✓ The correct public or confidential client type is registered
✓ Public clients use Authorization Code with PKCE
✓ PKCE uses S256
✓ Redirect URIs are exact and controlled
✓ Transaction values are generated and validated correctly
✓ OpenID Connect ID tokens are validated when used
✓ APIs validate access tokens and scopes
✓ Requested scopes follow least privilege
✓ Tokens and secrets are removed from logs
✓ Refresh-token replay protection is supported
✓ Cookie sessions include CSRF protection
✓ Separate clients exist for each environment and platform
✓ Login, error, logout, expiration, and revocation paths were tested
✓ A maintained OAuth or OpenID Connect library is used

When to Request a Security Review

Professional review is particularly important when OAuth protects:

  • Money movement or payment administration.
  • Healthcare or regulated records.
  • Account recovery and identity verification.
  • Administrative or support functions.
  • Multi-tenant business systems.
  • Enterprise federation and single sign-on.
  • Highly privileged API scopes.
  • Native applications using custom redirect schemes.
  • Complex logout, token exchange, delegation, or impersonation workflows.

The review should cover more than whether login succeeds. It should examine redirect handling, client registration, token validation, scopes, refresh behavior, sessions, CSRF, XSS exposure, API authorization, logout, revocation, logging, and incident response.

Conclusion

Implementing OAuth without exposing client secrets begins with accepting that browsers, mobile apps, desktop apps, and extensions are public clients. A static credential shipped with those applications cannot provide reliable client authentication.

Use Authorization Code with PKCE and the S256 challenge method for public clients. Register exact redirect URIs, limit scopes, validate every authorization transaction, and protect tokens according to the application’s architecture.

For sensitive browser applications, a backend-for-frontend can keep OAuth access and refresh tokens on the server and give the browser only a protected session cookie. This reduces direct token exposure but still requires strong session, CSRF, XSS, dependency, and backend security.

The objective is not to conceal a credential inside public code. It is to design a flow that remains secure without depending on that impossible guarantee.

Frequently Asked Questions

Can I hide a client secret using environment variables?
A server-side environment variable can protect a secret when only the protected backend reads it. A frontend build variable does not provide confidentiality if the value is bundled into JavaScript delivered to users. Inspect the final build rather than assuming that the variable name makes the value private.
Does PKCE make a SPA a confidential client?
No. PKCE protects the authorization-code exchange by requiring a temporary verifier. It does not allow a browser application to protect a permanent credential, and it does not prevent malicious JavaScript from using tokens after they are issued.
Should a SPA store tokens in localStorage?
localStorage is convenient, but any malicious JavaScript running in the same origin can read it, and the data persists across page reloads. A BFF offers stronger token isolation. When a browser must handle tokens directly, compare memory, worker-based, and persistent storage carefully and keep scopes and lifetimes limited.
Do I need state when using PKCE?
PKCE and state are related but not identical mechanisms. Correctly enforced PKCE can provide transaction and CSRF protections in supported flows, while state is commonly used to bind the response to a client transaction. Use a trusted library and follow the authorization server’s documented requirements rather than removing state without a complete security analysis.
Should mobile applications include a client secret?
A secret included in a mobile package can be extracted and should not be treated as confidential. Mobile applications should normally use Authorization Code with PKCE and open the authorization request in the system browser or another approved external user-agent.
Is a BFF always required?
No. A browser-only public client with Authorization Code and PKCE can be appropriate in some applications. A BFF adds infrastructure and session responsibilities but offers stronger isolation for OAuth tokens, making it attractive for sensitive or privileged browser applications.

Official References

Editorial note: The code and request examples in this guide are illustrative. Use a maintained OAuth or OpenID Connect library and follow the current documentation of your authorization server instead of building a production protocol implementation from isolated snippets.