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:
-
Password grant is gone. You cannot keep logging customers
in with a single
POST /oauth/tokenandgrant_type=password. You need authorization code flow (with PKCE for public clients). -
Token requests belong in the POST body. Putting
client_id,grant_type, or secrets in the query string stops working. Grants useapplication/x-www-form-urlencodedin the body (SAP documents this in KBA 3780745). - 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
| 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
|
| PKCE | None |
code_challenge / code_verifier (S256)
|
| Session | httpOnly customer cookies | httpOnly 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
continuewas 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 }
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.
| Setting | Example |
|---|---|
registeredRedirectUri | http://localhost:3000/api/auth/pkce/callback |
loginPageUri | http://localhost:3000/en/login |
NEXT_PUBLIC_APP_URL | Same 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
| Symptom | Likely cause |
|---|---|
Loop back to /pkce/start | Authorize failed at flow start; check Commerce client and logs |
| CSRF 400, login page configuration mismatch | loginPageUri vs actual login URL Referer/Origin |
| Credentials OK, no session | Missing authorize resume with continue |
FLOW_RESTART_REQUIRED | Expired 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
localStoragepersistence; 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.