The authorization code flow is the most common way to do OAuth 2.0 and OIDC from a browser-based app. It roughly works like this:
- The browser is redirected to the provider, the user logs in, and the provider returns an authorization code.
- The browser then sends that code to the token endpoint and gets tokens (access token, ID token, and refresh token) in response.
- The browser can then use those tokens to call APIs or get user info.
- If the provider issues refresh tokens, the browser can also use them to get new access tokens when the current one expires.
In this blog post we will see how to implement the authorization code flow with PKCE, and then how to implement it in a BFF (Backend For Frontend) architecture for better security.
General implementation notes ¶
The OpenID Provider (OP) / Authorization Server (AS) in this demo is a simple mock implementation that supports the necessary endpoints and PKCE validation, but keeps user management intentionally simple. It only knows the two demo users alice and bob, and skips the UI when a login_hint is provided. The provider also supports token introspection, which allows resource APIs to check if an access token is still active.
You find the implementation of the provider in this GitHub repository.
The frontend is written in Angular and uses the angular-oauth2-oidc library to handle the OAuth 2.0 and OIDC flows. The BFF backend implementation is written in Go and uses github.com/coreos/go-oidc/v3, golang.org/x/oauth2, github.com/golang-jwt/jwt/v5, github.com/go-chi/chi/v5, and github.com/alexedwards/scs/v2.
PKCE ¶
The problem with the authorization code flow is that it was originally designed for confidential clients, meaning server-side applications that could keep a client secret private. People started using it from SPAs and native apps anyway, but these clients are not confidential because they run in public environments and therefore cannot keep a secret. That meant that an attacker who can intercept the authorization code from the redirect can exchange it for tokens and impersonate the user.
For this reason PKCE (Proof Key for Code Exchange) was added as an extension to the authorization code flow. PKCE adds a one-time secret that the client creates at the start of the flow and must present at the end of the flow, so an intercepted code cannot be redeemed without that secret.
Here is how the authorization code flow with PKCE works in practice.
login_hintis only used in this demo to avoid building a full login UI in the main flow. When it is present, the provider can authenticate the selected demo user directly.nonceandstateare not technically PKCE parameters, but they are important for security and are commonly used together with PKCE in the browser.statelets the client correlate the redirect response to the login attempt it started and must be verified after the redirect.nonceprotects against ID token replay and must be verified inside the ID token after the token exchange. Both values are opaque random strings.code_verifier: a high-entropy cryptographically random string. RFC 7636 §4.1 requires a minimum length of 43 characters and recommends at least 256 bits of entropy (32 random bytes). A way to generate this in a browser is with the Web Crypto API:crypto.getRandomValues(new Uint8Array(32)), then base64url-encode the result.code_challenge: a derived value from thecode_verifier. Per RFC 7636 §4.2 the S256 method (the only mandatory-to-implement method) is computed asBASE64URL-ENCODE(SHA256(ASCII(code_verifier)))— hash the verifier with SHA-256 and then base64url-encode the resulting bytes.
In this flow we see how PKCE protects the authorization code exchange, by sending the derived code_challenge in the /authorize request and then requiring the original code_verifier in the /token request. The provider can verify that the code_verifier matches the original code_challenge, so if an attacker intercepts the authorization code but does not have the matching code_verifier, they cannot exchange it for tokens.
Replaying the same /token request also usually does not help because authorization codes are expected to be single-use and a replay should fail. This is not PKCE-specific, but a common best practice for the authorization code flow. It is also essential to run the flow over HTTPS, so that the authorization code and the PKCE parameters are protected in transit.
In OAuth 2.0 a resource API must validate the access token in each request. This can be done locally by downloading the public keys from the provider and validating the JWT signature and claims. The drawback is that if the provider supports token revocation, the resource API does not automatically see that revocation if it only validates the JWT locally.
One way to solve that problem is for the resource API to call the provider's introspection endpoint to check if the token is still active. The drawback with this approach is that it adds latency to each API call, but it allows for immediate revocation visibility.
Another common production pattern is to only use short-lived access tokens (5 to 15 minutes) and require the client to refresh them frequently. Revocation is then enforced at the refresh-token or session layer, so once a user logs out or an admin revokes access, the client can no longer get a new access token. This approach is more performant because the resource API can validate the JWT locally without an introspection call, but it means that revocation is not visible until the next refresh.
Implementation details ¶
The frontend in this demo application uses angular-oauth2-oidc for the heavy lifting of the OAuth 2.0 and OIDC flows.
The PKCE frontend configures the library as a public OAuth client that uses the authorization code flow. The provideOAuthClient setup tells Angular's HTTP client to automatically attach the access token when the browser calls the protected resource API.
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideHttpClient(withFetch(), withInterceptorsFromDi()),
provideRouter(routes),
provideOAuthClient({
resourceServer: {
allowedUrls: [environment.resourceApiUrl],
sendAccessToken: true,
},
}),
],
};
To log in the user the application calls beginLogin, and the OAuth library creates state, nonce, and the PKCE code_verifier in browser storage before redirecting to /authorize. In this demo the selected user is passed as login_hint to the provider, so the provider can authenticate the user automatically.
async beginLogin(user: DemoUser): Promise<void> {
await this.initializeAuth();
this.busy.set(true);
this.error.set(null);
this.resourceApiPayload.set(null);
try {
this.oauthService.initLoginFlow('', { login_hint: user });
} catch (error: unknown) {
this.error.set(readErrorMessage(error, 'Failed to start the PKCE flow.'));
this.busy.set(false);
}
}
When the provider redirects the browser back to http://localhost:4200/callback?code=...&state=..., the frontend boots again and loadDiscoveryDocumentAndTryLogin() completes the code exchange. If the redirect contains a valid authorization response, angular-oauth2-oidc sends the POST /token request, stores the returned token set in sessionStorage, and the app then fetches userinfo so the UI can display the current user.
private async initializeAuthInternal(): Promise<void> {
this.busy.set(true);
this.error.set(null);
try {
await this.oauthService.loadDiscoveryDocumentAndTryLogin();
this.oauthService.setupAutomaticSilentRefresh();
if (this.oauthService.hasValidAccessToken()) {
await this.loadUserInfo();
}
} catch (error: unknown) {
this.error.set(readErrorMessage(error, 'Initializing OAuth login failed.'));
} finally {
this.syncAuthSnapshot();
this.busy.set(false);
}
}
async loadUserInfo(): Promise<void> {
const hasAccessToken = await this.ensureFreshAccessToken();
if (!hasAccessToken) {
return;
}
try {
const userInfo = normalizeUserInfo(await this.oauthService.loadUserProfile());
this.userInfo.set(userInfo);
} catch (error: unknown) {
this.error.set(readErrorMessage(error, 'Fetching userinfo failed.'));
}
}
After login the browser can directly call both userinfo and the protected API. Because the Angular OAuth client registered environment.resourceApiUrl as an allowed resource server URL, the access token is attached automatically to the API request. The ensureFreshAccessToken method checks if the access token is expired or about to expire and tries to refresh it if possible, so the API call is only made if there is a valid access token available.
async callResourceApi(): Promise<void> {
this.busy.set(true);
this.error.set(null);
const hasAccessToken = await this.ensureFreshAccessToken();
if (!hasAccessToken) {
this.error.set('No access token is available in browser storage.');
this.busy.set(false);
return;
}
try {
const payload = await firstValueFrom(
this.http.get<ResourceApiPayload>(environment.resourceApiUrl),
);
this.resourceApiPayload.set(payload);
} catch (error: unknown) {
this.error.set(readErrorMessage(error, 'Calling the resource API failed.'));
} finally {
this.busy.set(false);
}
}
On the Resource API side, the API validates the access token in each request. In this demo the API calls the provider's introspection endpoint to check if the token is still active. This adds latency to each request, but allows for immediate revocation visibility. As mentioned before, a common production pattern is to use short-lived access tokens and refresh them frequently, so that the resource API can validate the JWT locally without an introspection call, but revocation is not visible until the next refresh.
func (b *bffApp) handleProfile(w http.ResponseWriter, r *http.Request) {
token := bearerToken(r.Header.Get("Authorization"))
if token == "" {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
b.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "missing bearer token"})
return
}
payload, err := b.resourceProfilePayload(token)
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
b.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": err.Error()})
return
}
b.writeJSON(w, http.StatusOK, payload)
}
func (b *bffApp) resourceProfilePayload(token string) (map[string]any, error) {
claims, err := b.validateResourceAccessToken(token)
if err != nil {
return nil, err
}
roles := readStringSliceClaim(claims, "roles")
message := "Reader data: you can inspect profile information."
if slices.Contains(roles, "admin") {
message = "Admin data: you can inspect deployment switches and role-gated data."
}
return map[string]any{
"subject": readStringClaim(claims, "sub"),
"name": readStringClaim(claims, "name"),
"email": readStringClaim(claims, "email"),
"roles": roles,
"scope": readStringClaim(claims, "scope"),
"message": message,
"token": map[string]any{
"issuer": readStringClaim(claims, "iss"),
"audience": readStringClaim(claims, "aud"),
"clientId": readStringClaim(claims, "client_id"),
"expiresAtEpoch": readNumericClaim(claims, "exp"),
},
}, nil
}
PKCE only protects redemption of the authorization code. It does not help if an attacker is able to steal the access token or the refresh token after the token exchange. Whoever has the access token can call APIs as the user until the token expires, and whoever has the refresh token can keep minting new access tokens until that refresh token is revoked, rotated away, or expires.
Another issue is that the tokens are stored in a public client (the browser), so if an attacker can get access to these tokens, they can impersonate the user. We see in the next section how a BFF (Backend For Frontend) architecture can help mitigate these issues.
BFF (Backend For Frontend) ¶
As mentioned before, running the authorization code flow in a non-confidential client like a browser has some security implications, because the tokens are stored in the browser and can be stolen by an attacker who can run malicious JavaScript or intercept the token response.
A solution to that problem is to use a BFF (Backend For Frontend) architecture, where the backend is the OAuth client and holds the tokens server-side, while the browser only has an opaque session cookie that references server-side state instead of containing the OAuth tokens. The BFF backend can then call the provider's APIs with the access token and return a proxied response to the browser, so the browser never has direct access to the tokens.
To implement the authorization code flow in a server-side component we could technically skip PKCE because the backend is considered a confidential client and can keep a client secret private. But Best Current Practice for OAuth 2.0 Security (RFC 9700, Section 2.1.1) recommends PKCE for confidential clients as well. The reason is not only that PKCE protects public clients from a stolen authorization code being redeemed directly, but also that it protects confidential clients against authorization code misuse and injection attacks. In a BFF architecture, that still matters because the browser is part of the redirect flow, so a code exposed in the front channel can still be abused even if the token exchange itself happens on the backend.
A BFF architecture with the authorization code flow and PKCE looks like this:
In this demo the BFF backend and the resource API are the same component, but this could also be split into two separate components if desired.
The flow starts a bit differently because the browser is not the OAuth client, so it cannot start the flow by redirecting directly to the provider. Instead, the browser makes a request to the BFF backend to start the login process, and then the BFF creates the PKCE parameters and redirects the browser to the provider. After authenticating the user, the provider sends a redirect response to the browser, which then sends a request to the BFF callback endpoint with the authorization code. The BFF backend then verifies the PKCE parameters, exchanges the code for tokens, creates a server-side session, and finally sends a response to the browser with a session cookie. The browser then uses that session cookie for subsequent requests to the BFF, and the BFF is responsible for checking the session, validating or refreshing tokens as needed, and executing protected logic.
The advantage of this architecture is that the browser never has direct access to the OAuth tokens, so even if an attacker can run malicious JavaScript in the browser, they cannot steal those tokens from browser storage. Another nice side effect is that requests sent to the BFF backend do not need to include an Authorization header with a bearer token, which can be large if it carries a lot of claims and scopes. Instead, the session cookie is small and is sent automatically by the browser. Session cookies are also protected by the HttpOnly, and Secure flags, which makes them more resistant to XSS and CSRF attacks. The Secure flag must always be enabled in production so the browser only ever sends the cookie over HTTPS.
The drawback is that this architecture requires some server-side component, so it is not suitable for a pure static deployment.
Implementation details ¶
In the BFF architecture, the frontend cannot start the login flow by redirecting directly to the provider. Instead, it has to call a BFF endpoint that creates the PKCE parameters and then redirects to the provider. In this demo, the frontend calls beginLogin with a selected user, which sends a request to the BFF login endpoint with that user as a query parameter.
beginLogin(user: DemoUser): void {
const loginUrl = new URL(environment.loginUrl);
loginUrl.searchParams.set('user', user);
window.location.assign(loginUrl.toString());
}
The BFF login endpoint creates the PKCE values server-side, stores them in a pending login record keyed by state, and then uses the golang.org/x/oauth2 library to build the authorization request. This request is sent to the browser as a redirect, and the browser then follows that redirect to the provider.
func (b *bffApp) handleLogin(w http.ResponseWriter, r *http.Request) {
user := strings.ToLower(r.URL.Query().Get("user"))
if user != "alice" && user != "bob" {
b.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "user must be alice or bob"})
return
}
state, err := randomToken(24)
if err != nil {
b.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
nonce, err := randomToken(24)
if err != nil {
b.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
codeVerifier, err := randomToken(32)
if err != nil {
b.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
b.mu.Lock()
b.pending[state] = pendingLogin{User: user, Nonce: nonce, CodeVerifier: codeVerifier, ExpiresAt: time.Now().Add(5 * time.Minute)}
b.mu.Unlock()
authorizeURL := b.oauthConfig.AuthCodeURL(
state,
oauth2.S256ChallengeOption(codeVerifier),
oauth2.SetAuthURLParam("nonce", nonce),
oauth2.SetAuthURLParam("login_hint", user),
)
http.Redirect(w, r, authorizeURL, http.StatusFound)
}
After a successful login, the provider sends a redirect response to the browser with the authorization code. This redirect points to the BFF callback endpoint, which is responsible for completing the flow. It verifies the state parameter, then exchanges the authorization code for tokens by calling the provider's token endpoint with the appropriate parameters, including the code_verifier for PKCE. After receiving the tokens, it validates the ID token and access token, verifies the nonce to prevent ID token replay, creates a server-side session with the tokens and user info, and finally redirects the browser to the frontend with a session cookie. In essence, these are all the same steps that we have seen in the first example, but now they are executed on the backend instead of in the browser.
func (b *bffApp) handleCallback(w http.ResponseWriter, r *http.Request) {
state := r.URL.Query().Get("state")
code := r.URL.Query().Get("code")
b.mu.Lock()
pending, ok := b.pending[state]
if ok {
delete(b.pending, state)
}
b.mu.Unlock()
if !ok || time.Now().After(pending.ExpiresAt) {
b.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "state not found or expired"})
return
}
tokens, err := b.exchangeCode(code, pending.CodeVerifier)
if err != nil {
b.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
idClaims, err := b.validateJWTClaims(tokens.IDToken, b.clientID)
if err != nil {
b.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "id_token validation failed: " + err.Error()})
return
}
if nonce := readStringClaim(idClaims, "nonce"); nonce != pending.Nonce {
b.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "nonce mismatch"})
return
}
accessClaims, err := b.validateJWTClaims(tokens.AccessToken, b.resourceAudience)
if err != nil {
b.writeJSON(w, http.StatusBadGateway, map[string]string{"error": "access_token validation failed: " + err.Error()})
return
}
sessionID, err := randomToken(32)
if err != nil {
b.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": err.Error()})
return
}
// The browser gets only an opaque session cookie; tokens stay on the server.
savedSession := &browserSession{
ID: sessionID,
User: userProfileFromClaims(idClaims),
AccessToken: tokens.AccessToken,
AccessTokenExp: readNumericClaim(accessClaims, "exp"),
RefreshToken: tokens.RefreshToken,
RefreshTokenExp: time.Now().Add(45 * time.Minute),
Scope: tokens.Scope,
CreatedAt: time.Now(),
}
if err := b.sessionManager.RenewToken(r.Context()); err != nil {
b.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to renew browser session"})
return
}
b.sessionManager.Put(r.Context(), browserSessionKey, savedSession)
http.Redirect(w, r, b.frontendOrigin, http.StatusFound)
}
Whenever the browser sends a request to the backend it includes the session cookie, so the BFF can load the session and check if the access token is still valid. If the access token is expired or about to expire, the BFF refreshes it using the refresh token. If everything fails, the BFF can return an error response and the frontend can start the login flow again. If the access token is valid, the BFF can execute the protected logic, in this case the call to resourceProfilePayload, and return a response to the browser.
func (b *bffApp) handleData(w http.ResponseWriter, r *http.Request) {
storedSession, ok := b.sessionFromRequest(r)
if !ok {
b.writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "not authenticated"})
return
}
if err := b.ensureFreshAccessToken(r.Context(), storedSession); err != nil {
b.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
requestTrace := traceRequest{
Method: http.MethodGet,
URL: b.resourceAPIURL,
Headers: map[string]string{"Authorization": "Bearer [server-side token]"},
Notes: "BFF invokes the colocated resource API with the server-held access token.",
}
payload, err := b.resourceProfilePayload(storedSession.AccessToken)
if err != nil {
b.traceLogger.Write("resource_api", requestTrace, nil, err)
b.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
return
}
b.traceLogger.Write("resource_api", requestTrace, &traceResponse{StatusCode: http.StatusOK, Body: payload}, nil)
b.writeJSON(w, http.StatusOK, map[string]any{
"proxied": true,
"browserHasToken": false,
"resourceData": payload,
})
}
Wrapping up ¶
The authorization code flow with PKCE is the recommended way to do OAuth 2.0 and OIDC from a browser-based app. It protects the authorization code exchange with a one-time secret, so an intercepted code cannot be redeemed without that secret. However, it does not protect the tokens themselves if an attacker can steal them from the browser after the token exchange or run malicious JavaScript in the page. For that reason, a more secure architecture is to use a BFF (Backend For Frontend) pattern, where the backend is the OAuth client and holds the tokens server-side, while the browser only has an opaque session cookie.