OAuth with SPAs and a Shared BFF
GitHub: github.com/mnafshin/oauth
What we are building
A backend-for-frontend (BFF) that handles OAuth for two single-page apps:
React (Vite, port 5173)
Angular (port 4200)
Both SPAs share one Spring Boot BFF on port 8083. The BFF:
Holds the
client_secretand exchanges authorization codesStores 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 (
returnToURL)
The SPAs never see access tokens, refresh tokens, or client_secret. That is the point.
Repo: oauth-demo → modules/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
Open the SPA home page
Click Login with Keycloak
Sign in as
alice/passwordLand on
/dashboardwith 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 cookiesapp.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:
fetch/HttpClientusescredentials: 'include'/withCredentials: trueCORS allows credentials from the SPA origin
The cookie is not
SameSite=Strictblocking 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,
returnTowhitelist[ ] 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