Skip to main content

Command Palette

Search for a command to run...

OAuth with SPAs and a Shared BFF

Updated
7 min readView as Markdown

GitHub: github.com/mnafshin/oauth


What we are building

A backend-for-frontend (BFF) that handles OAuth for two single-page apps:

  1. React (Vite, port 5173)

  2. Angular (port 4200)

Both SPAs share one Spring Boot BFF on port 8083. The BFF:

  • Holds the client_secret and exchanges authorization codes

  • Stores tokens server-side and issues a session cookie

  • Exposes a small REST API the SPAs call with credentials: 'include'

  • Redirects back to whichever SPA started login (returnTo URL)

The SPAs never see access tokens, refresh tokens, or client_secret. That is the point.

Repo: oauth-demomodules/frontend/ (bff/, react/, angular/)

Server-rendered HTML? See post 6 — Thymeleaf on port 8080 uses the same Spring OAuth skills but no CORS or cross-origin cookies.


Why not OAuth inside the browser?

Post 3 explained Authorization Code + PKCE for public clients. That works, but for typical product SPAs teams prefer a BFF because:

Concern Browser-only SPA BFF
client_secret Cannot store safely Stays on server
Refresh token Risky in JS storage Server-side only
Token exposure XSS can steal tokens HttpOnly session cookie
API calls Bearer token in JS Cookie + optional CSRF

The BFF pattern trades a small backend for a simpler security story in the browser.


Architecture


Repo layout

modules/frontend/
├── bff/       # Gradle module :spa-bff — Spring Boot OAuth + REST
├── react/     # Vite + React + TypeScript
└── angular/   # Angular standalone components

Gradle registers the BFF as :spa-bff in settings.gradle. The Thymeleaf demo (modules/spring-boot) is intentionally separate — compare both patterns side by side.


Run locally

# Terminal 1 — IdP (imports spa-bff client + app-user role for alice)
make start-keycloak

# Terminal 2 — shared BFF
export KEYCLOAK_SPA_BFF_SECRET=spa-bff-secret
make start-bff

# Terminal 3 — pick one SPA
make start-react      # http://localhost:5173
# or
make start-angular    # http://localhost:4200
  1. Open the SPA home page

  2. Click Login with Keycloak

  3. Sign in as alice / password

  4. Land on /dashboard with username and email

/api/dashboard requires the spa-bff client role app-user — same role name as the Thymeleaf demo, but on a different Keycloak client.

Build everything

make clean-build   # Gradle test + build + both SPA production bundles

Each module has its own Makefile with start, build, and clean targets.


BFF configuration

modules/frontend/bff/src/main/resources/application.properties:

spring.application.name=oauth-bff
server.port=8083

spring.security.oauth2.client.registration.keycloak.client-id=spa-bff
spring.security.oauth2.client.registration.keycloak.client-secret=${KEYCLOAK_SPA_BFF_SECRET:spa-bff-secret}
spring.security.oauth2.client.registration.keycloak.scope=openid,profile,email
spring.security.oauth2.client.registration.keycloak.authorization-grant-type=authorization_code
spring.security.oauth2.client.registration.keycloak.redirect-uri={baseUrl}/login/oauth2/code/{registrationId}

spring.security.oauth2.client.provider.keycloak.issuer-uri=http://localhost:8081/realms/oauth-demo

app.cors-allowed-origins[0]=http://localhost:5173
app.cors-allowed-origins[1]=http://localhost:4200
app.allowed-return-urls[0]=http://localhost:5173
app.allowed-return-urls[1]=http://localhost:4200
  • app.cors-allowed-origins — browsers on 5173/4200 may call the BFF with cookies

  • app.allowed-return-urls — whitelist for post-login redirect (open redirect protection)


BFF security highlights

Compared to the Thymeleaf app (post 6), the BFF adds three SPA-specific concerns:

1. CORS with credentials

config.setAllowedOrigins(appProperties.corsAllowedOrigins());
config.setAllowCredentials(true);

Without AllowCredentials, the browser will not send the session cookie on cross-origin fetch calls.

2. CSRF for state-changing requests

.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)

SPAs read the XSRF-TOKEN cookie and send X-XSRF-TOKEN on POST /logout. Thymeleaf embeds CSRF in forms instead.

3. API returns 401, not a login redirect

.exceptionHandling(ex -> ex
    .authenticationEntryPoint(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED))
)

SPAs handle 401 in JavaScript (show a login button). Server-rendered apps redirect to /oauth2/authorization/keycloak automatically.

4. Custom login entry with returnTo

@GetMapping("/api/auth/login")
public void login(@RequestParam("returnTo") String returnTo, ...) {
    if (!isAllowedReturnUrl(returnTo)) {
        throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "returnTo is not allowed");
    }
    session.setAttribute("OAUTH_RETURN_TO", returnTo);
    response.sendRedirect("/oauth2/authorization/keycloak");
}

After OAuth succeeds, a custom AuthenticationSuccessHandler reads OAUTH_RETURN_TO from the session and redirects the browser back to React or Angular.

5. Role mapping from spa-bff claims

Same pattern as post 6, but the client ID in resource_access is spa-bff:

Map<String, Object> clientAccess =
        (Map<String, Object>) resourceAccess.get("spa-bff");

REST API

Endpoint Auth Purpose
GET /api/auth/login?returnTo=... Public Start OAuth, remember SPA URL
GET /api/me Session Current user JSON
GET /api/dashboard Session + ROLE_app-user Protected demo data
POST /logout Session + CSRF App logout + OIDC logout

React client

modules/frontend/react/src/api/bff.ts — thin wrapper around fetch:

export const BFF_URL = import.meta.env.VITE_BFF_URL ?? 'http://localhost:8083'

export function login(returnTo: string): void {
  const url = new URL(`${BFF_URL}/api/auth/login`)
  url.searchParams.set('returnTo', returnTo)
  window.location.href = url.toString()
}

export async function fetchDashboard(): Promise<SessionUser> {
  const response = await fetch(`${BFF_URL}/api/dashboard`, {
    credentials: 'include',
  })
  if (!response.ok) throw new Error(`Dashboard request failed: ${response.status}`)
  return response.json()
}

export async function logout(): Promise<void> {
  const csrfToken = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/)?.[1]
  await fetch(`${BFF_URL}/logout`, {
    method: 'POST',
    credentials: 'include',
    headers: csrfToken ? { 'X-XSRF-TOKEN': decodeURIComponent(csrfToken) } : {},
  })
  window.location.href = '/'
}

The dashboard page calls fetchDashboard() on mount. If the user is not logged in, the BFF returns 401 and the UI shows a login button.


Angular client

modules/frontend/angular/src/app/auth.service.ts — same contract, Angular idioms:

const BFF_URL = 'http://localhost:8083';

login(returnTo: string): void {
  const url = new URL(`${BFF_URL}/api/auth/login`);
  url.searchParams.set('returnTo', returnTo);
  window.location.href = url.toString();
}

fetchDashboard(): Observable<SessionUser> {
  return this.http.get<SessionUser>(`${BFF_URL}/api/dashboard`, {
    withCredentials: true,
  });
}

provideHttpClient(withFetch()) in app.config.ts enables the Fetch API backend. Components inject AuthService — no OAuth library required because the BFF owns the protocol.


Cross-origin cookies in local dev

React runs on localhost:5173, the BFF on localhost:8083. These are different origins (port matters). The session cookie is set for :8083 during the OAuth callback; the SPA sends it back on API calls because:

  1. fetch / HttpClient uses credentials: 'include' / withCredentials: true

  2. CORS allows credentials from the SPA origin

  3. The cookie is not SameSite=Strict blocking cross-site subrequests in this dev setup

In production, teams often serve the SPA and BFF under one parent domain (e.g. app.example.com and app.example.com/api) to simplify cookies — or use a reverse proxy on a single origin.


Thymeleaf vs BFF vs plain Java

Thymeleaf (post 6) BFF (this post) Plain Java (post 7)
Port 8080 8083 8082
Keycloak client orders-web spa-bff orders-web
UI Server HTML React / Angular Hand-rolled HTML
OAuth code Spring Security Spring Security Manual HttpClient
CORS Not needed Required Not needed
CSRF Form hidden field Cookie + header You implement

All three enforce app-user — the authorization rule is the same; only the integration shape changes.


Go deeper

Serving the SPA from the BFF (single origin)

For production, build the React/Angular app and copy static files into src/main/resources/static on the BFF. One port, no CORS, simpler cookies. The OAuth redirect URI stays on the BFF host.

Token relay to downstream APIs

The BFF can call microservices with the user's access token server-side:

@RegisteredOAuth2AuthorizedClient("keycloak") OAuth2AuthorizedClient client

The SPA never sees the Bearer token.

Adding a third SPA

Add its dev-server origin to app.cors-allowed-origins and app.allowed-return-urls. No new Keycloak client required — that is why the BFF is shared.

Testing the BFF

ApiControllerTest uses MockMvc + oauth2Login() — same approach as post 6. End-to-end tests need Keycloak (docker-compose) or Testcontainers.


What you should know after this post

  • [ ] You understand why SPAs use a BFF instead of storing tokens in the browser

  • [ ] You can run the shared BFF with React or Angular against local Keycloak

  • [ ] You know the extra Spring Security pieces: CORS, CSRF cookie, 401 entry point, returnTo whitelist

  • [ ] You can explain when to use Thymeleaf OAuth (post 6) vs a BFF (this post)

  • [ ] You have seen the same authorization rule (app-user) across all demo apps