Blogs

Customer Login on SAP Commerce JDK 21: PKCE, OCC, and a Next.js BFF

We run a B2C headless storefront on Next.js with SAP OCC on the backend. From day one the browser did not call Commerce OAuth directly. Login and session handling go through our /api/auth/* Route Handlers, and tokens stay in httpOnly cookies, not in JavaScript-readable storage.


That worked until Commerce moved to JDK 21. The platform dropped Resource Owner Password Credentials (ROPC). Our old login path, a Route Handler posting grant_type=password to /authorizationserver/oauth/token, had to go. The replacement is authorization code flow with PKCE, a custom login page on the storefront, and a server-side authorize resume step that is easy to miss if you only read the OAuth diagram once.


This post is how we built that on Next.js: what JDK 21 changed, how the BFF fits together, the login sequence step by step, Commerce Backoffice settings that must match, and the failures we hit in logs.

What JDK 21 changed for storefront login

JDK 21 Commerce replaced the legacy oauth2 extension with a Spring Security 6 authorization server. For frontend teams, three shifts mattered on our project:

  1. Password grant is gone. You cannot keep logging customers in with a single POST /oauth/token and grant_type=password. You need authorization code flow (with PKCE for public clients).
  2. Token requests belong in the POST body. Putting client_id, grant_type, or secrets in the query string stops working. Grants use application/x-www-form-urlencoded in the body (SAP documents this in KBA 3780745).
  3. Client registration is stricter. Public vs confidential clients, redirect URIs, and custom login page URIs must line up with what the server expects (KBA 3641255 covers migration).

We studied SAP Composable Storefront (Spartacus) to see how SAP wires PKCE on composable. That research informed our flow; our production architecture stayed a custom Next.js BFF, not Spartacus in the browser.

Architecture: browser, BFF, authorization server, OCC

The rule we kept across JDK versions: the browser never sees OAuth access or refresh tokens. It holds httpOnly cookies. Commerce calls run on the server.

Browser (React)
  → /api/auth/* Route Handlers (Next.js BFF)
      → Commerce authorization server (oauth/authorize, /login, /oauth/token)
      → OCC GWS (e.g. users/current, cart, checkout)

Logged-in shoppers: BFF attaches the customer access token from cookies when calling OCC.


Guests: catalog and anonymous cart use a separate client_credentials service token fetched on the server (cached per instance). That path is unrelated to customer PKCE login but shares the same idea: no bearer token in localStorage.

Why we did not copy Spartacus token storage

Spartacus persists auth in localStorage via AuthStatePersistenceService so a refresh does not log the user out. Access tokens (and metadata) are readable by any script on the origin. Spartacus strips refresh_token before save; the access token remains in storage.


That is documented product behavior, not a misconfiguration. On our custom headless build we already committed to httpOnly session cookies on the BFF, so we did not mirror composable client-side persistence when we moved to PKCE. JDK 21 forced the grant and authorize dance to change; it did not force us to expose tokens to JavaScript.

From ROPC to BFF PKCE

Customer login before and after JDK 21 on the same BFF pattern
Pre-JDK 21 (ROPC on BFF)JDK 21+ (PKCE on BFF)
Browser calls/api/auth/* only/api/auth/pkce/* + other auth routes
Commerce OAuth POST /oauth/token with grant_type=password /oauth/authorize → custom /login → resume authorize → POST /oauth/token with code + code_verifier
PKCENone code_challenge / code_verifier (S256)
SessionhttpOnly customer cookieshttpOnly customer cookies (unchanged principle)

We briefly ran PKCE entirely in the browser during exploration (verifier in sessionStorage, token exchange visible in DevTools). That was a learning spike for the JDK 21 sequence, not production. What we ship keeps Commerce on the server end to end.

PKCE login walkthrough

These are the Commerce steps our BFF performs. Only the transport layer changed when we moved off the browser trial.

1. Start the flow

User opens the localized login page without a valid PKCE flow cookie. The UI redirects to GET /api/auth/pkce/start.


The BFF generates code_verifier, code_challenge, and state, calls Commerce GET /oauth/authorize, receives Set-Cookie: JSESSIONID, stores verifier + state + session id in a signed httpOnly flow cookie (short TTL, on the order of minutes), and redirects back to the login page.

2. Submit email and password

The form posts JSON to POST /api/auth/pkce/login (optionally after server-side reCAPTCHA verification). The BFF reads the flow cookie.

3. CSRF from Commerce

GET /authorizationserver/csrf with your registered public client_id and the stored JSESSIONID. Requests must send Referer and Origin matching the configured custom login page URL. If Commerce loginPageUri does not match, you get a login page configuration error.

4. Password POST to Commerce

POST /authorizationserver/login with email, username, password, and _csrf. A successful response is typically 302 to /authorizationserver/. That means credentials were accepted. It is not yet the OAuth callback with code.

5. Authorize resume (easy to skip)

After login, Commerce expects another GET /oauth/authorize with the same PKCE parameters plus continue (empty value is fine). The BFF follows the 302 to {APP_URL}/api/auth/pkce/callback?code=...&state=... and parses the authorization code from the Location header. The browser never navigates to that URL in the happy path.

If login succeeds but you never get a code, check whether authorize resume runs. Missing continue was a real bug class for us.

6. Token exchange

POST /oauth/token with grant_type=authorization_code, code, code_verifier, client_id, and redirect_uri identical to the authorize step. On JDK 21, all of that belongs in the POST body, not the query string.

7. Establish session

Call OCC users/current with the access token, then run session establishment on the BFF: if the shopper had an anonymous cart, merge it into the customer cart with the new bearer token, clear the guest cart cookie, set httpOnly customer cookies (access, refresh, user id), and clear the PKCE flow cookie. Return { success: true } to the client. The UI invalidates auth and cart queries and navigates to the post-login route.


Cart merge is easy to forget when you are focused on OAuth. A guest cart id usually lives in its own httpOnly cookie while catalog calls use the service token. After PKCE succeeds you must merge with the customer access token, not the guest token. If merge fails, we still complete login and log the error; the shopper may need to re-add lines. That is a separate debugging path from redirect URI or authorize resume issues.

Browser → GET /api/auth/pkce/start
  → BFF: Commerce /oauth/authorize, set flow cookie, redirect to /login

Browser → POST /api/auth/pkce/login { email, password }
  → BFF: CSRF, Commerce /login, authorize resume (+ continue), /oauth/token
  → BFF: OCC users/current, set customer httpOnly cookies, clear flow cookie

Browser → navigate; GET /api/auth/session → { isLoggedIn: true }
End-to-end PKCE password login on JDK 21. Commerce steps match Spartacus; the BFF performs them server-side.

GET /api/auth/pkce/callback remains a fallback if Commerce browser-redirects with code in the query string.

Commerce OAuth settings that must match

Each environment needs aligned Backoffice OAuth client settings and storefront env vars. Examples use localhost; production uses your real origin.

Typical alignment checklist
SettingExample
registeredRedirectUrihttp://localhost:3000/api/auth/pkce/callback
loginPageUrihttp://localhost:3000/en/login
NEXT_PUBLIC_APP_URLSame host as redirect and login page URIs

Older environments sometimes still register the storefront root as redirect_uri from pre-PKCE experiments. JDK 21 BFF login needs the callback path registered explicitly.

Session, refresh, and what the browser knows

After login, customer access and refresh tokens sit in httpOnly cookies. The client asks GET /api/auth/session for a boolean isLoggedIn, not for token strings.


Proactive refresh: middleware (or proxy) refreshes when the access cookie is missing but refresh and user id cookies still exist, before page or API handlers run.


Reactive refresh: cart and account BFF routes retry once after a GWS 401 by refreshing server-side and rewriting cookies.


Refresh uses the same JDK 21 rule: grant_type=refresh_token in a POST body to /oauth/token, not query parameters.

When login breaks: symptoms we saw

Common PKCE BFF failures
SymptomLikely cause
Loop back to /pkce/start Authorize failed at flow start; check Commerce client and logs
CSRF 400, login page configuration mismatchloginPageUri vs actual login URL Referer/Origin
Credentials OK, no sessionMissing authorize resume with continue
FLOW_RESTART_REQUIREDExpired flow cookie, clock skew, or parallel tabs
Token exchange 400 redirect_uri mismatch between authorize, token, Backoffice
Token exchange 400 after JDK upgrade Grant parameters still sent as query string instead of POST body

Structured server logs for authorize, CSRF, login submit, authorize resume, and token exchange made most of these obvious once we knew the sequence.

Limits we still accept

  • httpOnly cookies reduce token theft via XSS; they do not stop in-tab same-origin abuse while a session is active.
  • Mutating /api/* routes need CSRF or same-origin checks when cookies authenticate requests.
  • Teams on Composable Storefront may keep Spartacus localStorage persistence; that is a different client architecture than this BFF.

Someone once asked whether httpOnly cookies are pointless because DevTools still shows cookie values. HttpOnly means JavaScript on the page cannot read them. It does not hide them from the person at the keyboard. The threat we optimize for on a tagged-up storefront is malicious script exfiltration, not a developer inspecting their own session.

Closing

JDK 21 did not just tweak a token URL. It removed ROPC and forced a full authorization code + PKCE path with a custom Commerce login page and a resume step. Keeping that on a Next.js BFF let us preserve httpOnly sessions and OCC calls from the server while matching what the new authorization server expects.


If you are migrating a headless SAP storefront and want auth, performance, and checkout UX reviewed together on a live site, I cover OAuth and session behavior in the storefront audit. Otherwise reach me on LinkedIn or at karishmagarg.dev@gmail.com.

Further reading