How to Implement OAuth 2.0 Without Exposing Client Secrets

Secure OAuth 2.0 authorization flow protecting client credentials

A client secret is useful only when the application can keep it inside an environment controlled by its operator. A backend can usually meet that requirement. Browser JavaScript, mobile packages, desktop executables, and browser extensions cannot: users can inspect their code, bundles, source maps, network activity, or memory.

The architecture should therefore reflect the deployment boundary. Public clients should not contain a static secret disguised through environment variables, obfuscation, or build tooling. They normally use Authorization Code with Proof Key for Code Exchange (PKCE). A browser application with sensitive data may instead use a backend-for-frontend (BFF), keeping OAuth tokens out of browser JavaScript.

Classify the client before choosing a flow

A public client cannot reliably protect credentials distributed with the application. A confidential client runs in protected server infrastructure and can authenticate to the authorization server using a secret or another supported method.

A client ID is not a secret. It identifies the OAuth client and normally appears in authorization requests. Security must not depend on concealing it.

Also separate authorization from authentication. OAuth authorizes access to protected resources. If the application must establish the user’s identity, use OpenID Connect and validate the resulting ID token according to the provider’s requirements. Do not treat an arbitrary access token as proof of identity.

Architecture decision matrix

Architecture Where tokens live Main advantage Trade-off or remaining exposure Typical fit
Backend-for-frontend Server-side session OAuth tokens are not directly available to browser JavaScript Adds backend, cookie-session, CSRF, and proxy-security responsibilities Sensitive or privileged browser applications
Token-mediating backend Refresh token on server; access token may reach browser Limits browser exposure of the longer-lived refresh token Malicious frontend code can still use or steal an access token SPAs that must call an API directly
Browser-only SPA Browser memory or another browser-controlled location Does not require an application backend Tokens and authorized operations remain exposed to malicious same-origin JavaScript Lower-risk applications where a backend is impractical
Native public client Application memory and platform storage Works without shipping a confidential credential Redirect handling and device security require careful design Mobile and desktop applications
Confidential server client Protected server infrastructure Can protect client credentials and tokens from end users The server, deployment pipeline, and secret access controls become critical Server-rendered applications and machine-to-machine access

Backend-for-frontend: stronger token isolation, more server responsibility

A BFF completes the authorization-code exchange, stores access and refresh tokens on the server, and gives the browser an opaque session cookie. This changes the browser’s credential from an OAuth token to a session cookie; it does not eliminate browser security risks.

Illustrative BFF flow:

Browser -> BFF login endpoint -> Authorization server
Browser <- redirect <------------- Authorization server
Browser -> BFF callback with code
BFF     -> token endpoint with code + PKCE verifier
BFF     -> stores tokens server-side
Browser <- Secure, HttpOnly session cookie
Browser -> BFF -> approved protected API

The cookie should normally be Secure, HttpOnly, narrowly scoped, and configured with an appropriate SameSite policy. Cookie-authenticated state changes also need CSRF protection. Depending on the application, that can include CSRF tokens, Origin or Referer checks, method and content-type restrictions, and SameSite controls.

A BFF must not become a generic token or request proxy. Restrict identity-provider and API destinations to explicit approved hosts; otherwise, the proxy can introduce token disclosure, server-side request forgery, or confused-deputy behavior. Access to its server credentials should follow the same restricted-access principles used in a broader zero-trust DevOps design.

Finally, HttpOnly does not prevent malicious JavaScript from sending authenticated requests through the user’s active session. Output encoding, dependency control, Content Security Policy, and CSRF defenses still matter.

Direct SPA: less infrastructure, direct token exposure

A browser-only SPA is registered as a public client and uses Authorization Code with PKCE without a client secret. The provider must intentionally allow the browser origin to call its token endpoint. If that request fails, distinguish OAuth configuration from the browser behavior covered in this guide to resolving CORS errors in SPAs.

CORS is not authorization. It controls whether a browser origin may read a cross-origin response; the API must still validate every access token and enforce scopes or other authorization rules.

Keeping a token only in memory limits persistence, but active malicious code can still use it. Storing it in localStorage makes it survive reloads and leaves it readable by same-origin JavaScript. A worker can isolate some token operations from the main application context, but it cannot stop malicious code from abusing an active session or starting another authorization flow.

Native and server clients have different boundaries

Mobile and desktop applications should normally open authorization in the system browser or another approved external user-agent, use PKCE, and return through a provider-supported redirect mechanism. A secret compiled into the package must not be accepted as reliable client authentication.

Confidential server applications may retain credentials in a secret-management service or protected server configuration. Do not place those credentials in frontend build variables, public repositories, distributed images, CI/CD logs, monitoring events, or example code. Use separate OAuth registrations and credentials for development, staging, and production.

The Client Credentials flow belongs to protected machine-to-machine communication. It is not a substitute for a user authorization flow in a browser or native application.

What PKCE protects—and what it does not

PKCE binds an authorization request to its later code exchange:

  1. The client generates a new high-entropy code_verifier.
  2. It hashes the verifier with SHA-256 and Base64url-encodes the result as the code_challenge.
  3. The authorization request sends the challenge with code_challenge_method=S256.
  4. After receiving the authorization code, the client sends the original verifier to the token endpoint.
  5. The authorization server recalculates the challenge and rejects a mismatch.

This illustrative browser function generates the two PKCE values. A maintained OAuth or OpenID Connect library is preferable to assembling a production flow from individual snippets.

function toBase64Url(bytes) {
  const binary = Array.from(bytes, b => String.fromCharCode(b)).join("");
  return btoa(binary)
    .replace(/\+/g, "-")
    .replace(/\//g, "_")
    .replace(/=+$/, "");
}

async function createPkceValues() {
  const randomBytes = crypto.getRandomValues(new Uint8Array(32));
  const codeVerifier = toBase64Url(randomBytes);
  const digest = await crypto.subtle.digest(
    "SHA-256",
    new TextEncoder().encode(codeVerifier)
  );

  return {
    codeVerifier,
    codeChallenge: toBase64Url(new Uint8Array(digest))
  };
}

The authorization request includes the public client_id, exact redirect_uri, required scopes, challenge, S256 method, and transaction-protection values. The subsequent public-client exchange does not include a static client 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

PKCE protects the authorization-code exchange. It does not make a public client confidential, protect an access token after issuance, or prevent malicious JavaScript from acting through the legitimate application.

Implementation invariants across all architectures

Use exact registrations

Create separate client registrations for browser, mobile, desktop, server, and deployment environments. Register complete redirect URIs whenever the platform permits it, such as https://app.example.com/oauth/callback. Avoid broad wildcard redirects and callbacks that forward to arbitrary destinations.

Bind every callback to a transaction

Create a fresh PKCE verifier and appropriate transaction values for every authorization attempt. Compare the returned state when it is used, reject missing or expired transaction data, and delete temporary values after completion or abandonment.

PKCE, state, and an OpenID Connect nonce are related but not interchangeable. PKCE binds the code exchange, state commonly binds the callback to the client transaction, and nonce binds an ID token to an OpenID Connect request. Follow the provider and library requirements rather than removing one because another is present.

Validate tokens in the component that trusts them

The protected API decides whether an access token authorizes a request. Depending on the provider and token format, validation can include the signature, issuer, audience or resource, expiration, scopes, tenant, and environment. Opaque tokens may require the provider’s introspection endpoint.

Decoding a JWT only reveals its contents; it does not establish trust. An OpenID Connect ID token is intended for the client and should not normally be sent to an API in place of an access token.

Refresh tokens change the risk calculation

A refresh token may remain useful longer than an individual access token. When a provider issues refresh tokens to a public client, confirm that it supports replay protection through refresh-token rotation or sender-constrained refresh tokens, along with suitable expiration and revocation behavior.

A token-mediating backend can keep the refresh token off the browser while issuing short-lived access tokens to it. A full BFF goes further by retaining both token types. Neither design removes the need for frontend security.

Where supported by both the authorization server and API, DPoP can bind a token to a client-controlled key. It can reduce the usefulness of copying the token alone, but it does not replace PKCE, limited scopes, short lifetimes, or protection against malicious code running in the legitimate application context.

Verification sequence and expected failures

Run these checks only against systems you own or are authorized to assess. They complement, rather than replace, provider-specific conformance testing and defensive API security testing.

Checkpoint Verification action Expected secure result
Built application Inspect browser bundles, source maps, mobile packages, and public configuration for a client secret. No confidential client credential is present.
Redirect handling Review registered URIs and attempt an unregistered or altered redirect in an authorized test environment. The authorization server refuses the redirect.
PKCE exchange Exchange a valid code with a missing or changed verifier. The token endpoint rejects the request.
Callback transaction Return a missing, changed, expired, or previously consumed transaction value. The client stops the flow without exchanging the code.
Code replay Attempt to exchange the same authorization code again. The second exchange fails.
API validation Present an expired token, a token for another audience, or one without the required scope. The API denies access rather than relying on decoded claims alone.
BFF session Inspect cookie attributes and exercise state-changing requests without the required CSRF evidence. The cookie is protected and unauthorized cross-site actions are rejected.
Telemetry Review logs, analytics, error reports, and support payloads after login and failure paths. Codes, verifiers, tokens, cookies, and client secrets are absent or redacted.

The architecture choice can now be made from the remaining exposure: choose a BFF when keeping tokens out of browser JavaScript justifies the backend and session complexity; use a token-mediating backend when only refresh-token isolation is practical; use direct PKCE when a public client must handle tokens; and keep confidential credentials exclusively in protected server infrastructure.

Official references