OAuth 2.0, OpenID Connect, and a Browser SPA: How Identity Server and SPA App Talk

dev.to

Authentication looks simple until you ship it across several products, several domains, and more than one way to sign in.

We put login in one place: an identity service that authenticates the user once and issues tokens other apps can trust. Browser apps use Authorization Code + PKCE. We also have SSO and Google/Facebook on the identity server, not in each SPA.

This is how we actually built it, and why those choices stuck.


1. One identity service, many apps

If every product kept its own login, people would juggle passwords, you’d store secrets in more than one place, and hopping between apps would mean signing in again and again.

So we run a dedicated identity provider (IdP). It checks email and password (or Google/Facebook), issues tokens, keeps a session cookie for SSO, and publishes JWKS so APIs can verify signatures.

Product apps don’t handle passwords. The IdP is the credential store. APIs can still have their own users and memberships, keyed off the IdP sub, for roles, teams, and billing. They check the token, then apply their own rules.

Passwords live in one service. That’s the main reduction in risk.


2. OAuth 2.0 and OpenID Connect

We use both, and they answer different questions.

OAuth 2.0 is authorization: what may this app do? You get an access token to send to APIs.

OpenID Connect sits on OAuth and is authentication: who is this person? You get an ID token with sub, email, name, and so on.

When our SPA starts login it asks for:

openid profile offline_access product.api
Enter fullscreen mode Exit fullscreen mode

openid and profile are identity. product.api is “this access token is for our API.” offline_access is a refresh token so we can renew access without another login every few minutes.


3. Why Authorization Code + PKCE

A SPA runs in the browser. It can’t hide a client secret. That rules out a lot of older OAuth advice.

We don’t use implicit (tokens in the URL hash, and the industry has moved on). We don’t use resource owner password either: the SPA would collect email and password itself, skip the IdP UI, make MFA awkward, and teach people to type passwords into the app.

Authorization Code + PKCE is the usual choice for a public client:

  1. The SPA sends the user to the IdP.
  2. They sign in on the IdP’s origin — the address bar is the IdP, not the SPA.
  3. The IdP comes back with a short-lived code, not the tokens.
  4. The SPA swaps that code at /connect/token.
  5. PKCE is the proof: only the tab that created the code_verifier can finish the swap.

If someone steals the code (weird redirect, leaked query string), they still don’t have the verifier.

The IdP speaks normal OIDC: /.well-known/openid-configuration, /.well-known/jwks.json, /connect/authorize, /connect/token, /connect/logout.

The SPA has no confidential backend of its own. We still have product APIs; they just aren’t the login server.


4. SSO: sign in once

That’s a big reason to centralize. Sign in at the IdP, then other apps that trust it can get tokens without another password prompt.

Typical path: Product A redirects to the IdP, you authenticate, A gets tokens. Product B does the same redirect. The IdP already has a session cookie on its domain, so it skips the login page and issues tokens.

That cookie and the SPA’s tokens are not the same thing.

Where What it’s for
IdP cookie IdP host “Already signed in here” (SSO)
SPA tokens App host, this tab Calling APIs

The SPA cannot read the IdP cookie. Different site. The only way to find out if SSO is still alive is to go through /connect/authorize again.

That trips people up on invites. If you’re already signed in to the SPA, opening an invite link does not start OIDC. The tab still has tokens. We compare JWT email to the invited address and, if they differ, ask you to sign out and sign in as the invitee.

If you only clear SPA storage, the IdP cookie can stay. The next authorize may silently give you the same person again. That’s SSO working. To accept as someone else you need that other account: prompt=login, another browser profile, or a real IdP logout.

A valid token means we know who you are. Accepting an invite is an API rule: the email has to match.


5. Social login: our IdP talks to Google

“Sign in with Google” means our IdP is a client of Google, and still the IdP for our apps.

The SPA never talks to Google. It only starts PKCE against us.

If there’s no IdP session, you land on our /Account/Login: password, plus Google/Facebook when those are configured.

Then:

  1. You pick Google on the IdP.
  2. We challenge Google (ASP.NET Identity external login). Client id and secret stay on the server (/signin-google).
  3. Google sends the user back to us.
  4. We link or create an ASP.NET Identity user (provider + provider key).
  5. We set our cookie and resume authorize.
  6. The SPA gets a code at /auth/callback and exchanges it for our access, ID, and refresh tokens.

Same tokens as a password login. APIs never see Google’s tokens. The SPA doesn’t pass provider=Google; it doesn’t even draw those buttons.

We map the Google identity to our user so the same Google account keeps the same internal user. Exchanges with Google are server-side. We issue our own signed JWTs, which APIs already know how to check.


6. Token lifetime and refresh

Access tokens last 15 minutes. Refresh tokens last 14 days. ID tokens follow OpenIddict’s identity-token settings; we don’t treat a separate “15 minute ID token” as a hard number we configured by hand.

We keep all three in sessionStorage, one bundle per tab. Easy for a public SPA with no BFF. Weaker than an HttpOnly cookie. We live with that by using short access tokens, HTTPS, PKCE on the first exchange, and refresh checked at the IdP. A BFF that parked refresh tokens in HttpOnly cookies would be stricter; this harness doesn’t do that.

On API 401, the SPA posts grant_type=refresh_token to /connect/token. OpenIddict keeps refresh state server-side (expiry, revocation). You get a new access token, sometimes a new refresh token, and the original call is retried. No login UI unless refresh is dead too.

Short access tokens limit the window if one leaks. APIs verify JWTs with JWKS and don’t call the IdP on every request.


7. What the SPA does

Login is the full code + PKCE dance: random verifier, S256 challenge, state and nonce in sessionStorage, redirect to /connect/authorize, check state on /auth/callback, POST code + verifier to /connect/token, store tokens under one key for that tab.

Register is the same PKCE start, except the first hop is /Account/Register?returnUrl=… pointing at authorize. After the account exists, authorize continues and you still get a code.

Social login: one Sign in button. Google/Facebook show up on the IdP after the redirect.


8. What the identity server does

Authorize: check client, redirect URI, scopes, PKCE. If there’s an Identity cookie, continue as that user. If not, send them to login (or register). Then claims (sub, email, …) and an authorization code.

Token: code + verifier, PKCE check, then a signed JWT access token, a signed ID token, and a refresh token OpenIddict stores so we can expire or revoke it.

Access tokens are signed, not encrypted, so APIs can validate with JWKS: signature, iss, aud, exp, and scope when the endpoint cares. No IdP round-trip per request.


9. What we keep out of the token

The JWT has identity and scopes. It does not have company roles, team membership, or feature flags. Those change often. Putting them in the token would mean minting new tokens on every role change.

So: validate the JWT, read sub, load roles from the product database, then 200 or 403.

Invite accept is the same idea. The token says who you are. The API checks you’re the invited email. If not, 403 — the token can still be perfectly valid.

After accept we don’t run OIDC again. We POST with the Bearer we already have, then GET /me/memberships for the UI.


10. Logout (three sessions, not one)

With Google in the mix you can have three sessions: Google’s, our IdP cookie, and the SPA’s tokens.

SPA logout in the test app only clears sessionStorage. It does not hit /connect/logout, so the IdP cookie can remain and the next authorize may be silent SSO.

IdP /connect/logout signs out and revokes that user’s OpenIddict tokens. Refresh should fail after that. The SPA isn’t told until it tries. We have END_SESSION_URL in config; the harness still doesn’t call it. Wiring post_logout_redirect_uri would actually end SSO.

We don’t log people out of Google or Facebook. That’s their account, and the APIs aren’t a clean SLO story anyway.


If you’re in the same spot — several apps, a browser client, and a wish to stop copying login into every repo — this shape has worked for us: one IdP, PKCE for SPAs, tokens for APIs, membership left in the product.

(Thanks to a colleague and a language model for helping tidy the English.)

Tags: OAuth2 OpenID Connect PKCE SPA SSO Social Login ASP.NET Identity OpenIddict

Source: dev.to

arrow_back Back to News