<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: QingyanYang</title>
    <description>The latest articles on DEV Community by QingyanYang (@chelsaysomething).</description>
    <link>https://dev.to/chelsaysomething</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4099881%2F30ed7c67-7695-4fc0-97ba-7d927b9b1874.jpeg</url>
      <title>DEV Community: QingyanYang</title>
      <link>https://dev.to/chelsaysomething</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/chelsaysomething"/>
    <language>en</language>
    <item>
      <title>OAuth 2.0, OpenID Connect, and a Browser SPA: How Identity Server and SPA App Talk</title>
      <dc:creator>QingyanYang</dc:creator>
      <pubDate>Wed, 02 Sep 2026 13:07:27 +0000</pubDate>
      <link>https://dev.to/chelsaysomething/oauth-20-openid-connect-and-a-browser-spa-how-identity-server-and-spa-app-talk-54a8</link>
      <guid>https://dev.to/chelsaysomething/oauth-20-openid-connect-and-a-browser-spa-how-identity-server-and-spa-app-talk-54a8</guid>
      <description>&lt;p&gt;Authentication looks simple until you ship it across several products, several domains, and more than one way to sign in.&lt;/p&gt;

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

&lt;p&gt;This is how we actually built it, and why those choices stuck.&lt;/p&gt;




&lt;h2&gt;
  
  
  1. One identity service, many apps
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;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 &lt;code&gt;sub&lt;/code&gt;, for roles, teams, and billing. They check the token, then apply their own rules.&lt;/p&gt;

&lt;p&gt;Passwords live in one service. That’s the main reduction in risk.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. OAuth 2.0 and OpenID Connect
&lt;/h2&gt;

&lt;p&gt;We use both, and they answer different questions.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OAuth 2.0&lt;/strong&gt; is authorization: what may this app do? You get an &lt;strong&gt;access token&lt;/strong&gt; to send to APIs.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OpenID Connect&lt;/strong&gt; sits on OAuth and is authentication: who is this person? You get an &lt;strong&gt;ID token&lt;/strong&gt; with &lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, and so on.&lt;/p&gt;

&lt;p&gt;When our SPA starts login it asks for:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;openid profile offline_access product.api
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



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




&lt;h2&gt;
  
  
  3. Why Authorization Code + PKCE
&lt;/h2&gt;

&lt;p&gt;A SPA runs in the browser. It can’t hide a client secret. That rules out a lot of older OAuth advice.&lt;/p&gt;

&lt;p&gt;We don’t use &lt;strong&gt;implicit&lt;/strong&gt; (tokens in the URL hash, and the industry has moved on). We don’t use &lt;strong&gt;resource owner password&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Authorization Code + PKCE&lt;/strong&gt; is the usual choice for a public client:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;The SPA sends the user to the IdP.&lt;/li&gt;
&lt;li&gt;They sign in on the IdP’s origin — the address bar is the IdP, not the SPA.&lt;/li&gt;
&lt;li&gt;The IdP comes back with a short-lived &lt;strong&gt;code&lt;/strong&gt;, not the tokens.&lt;/li&gt;
&lt;li&gt;The SPA swaps that code at &lt;code&gt;/connect/token&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;PKCE is the proof: only the tab that created the &lt;code&gt;code_verifier&lt;/code&gt; can finish the swap.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If someone steals the code (weird redirect, leaked query string), they still don’t have the verifier.&lt;/p&gt;

&lt;p&gt;The IdP speaks normal OIDC: &lt;code&gt;/.well-known/openid-configuration&lt;/code&gt;, &lt;code&gt;/.well-known/jwks.json&lt;/code&gt;, &lt;code&gt;/connect/authorize&lt;/code&gt;, &lt;code&gt;/connect/token&lt;/code&gt;, &lt;code&gt;/connect/logout&lt;/code&gt;.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  4. SSO: sign in once
&lt;/h2&gt;

&lt;p&gt;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.&lt;/p&gt;

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

&lt;p&gt;That cookie and the SPA’s tokens are not the same thing.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;&lt;/th&gt;
&lt;th&gt;Where&lt;/th&gt;
&lt;th&gt;What it’s for&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;IdP cookie&lt;/td&gt;
&lt;td&gt;IdP host&lt;/td&gt;
&lt;td&gt;“Already signed in here” (SSO)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SPA tokens&lt;/td&gt;
&lt;td&gt;App host, this tab&lt;/td&gt;
&lt;td&gt;Calling APIs&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

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

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

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

&lt;p&gt;A valid token means we know who you are. Accepting an invite is an API rule: the email has to match.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Social login: our IdP talks to Google
&lt;/h2&gt;

&lt;p&gt;“Sign in with Google” means our IdP is a &lt;strong&gt;client&lt;/strong&gt; of Google, and still the &lt;strong&gt;IdP&lt;/strong&gt; for our apps.&lt;/p&gt;

&lt;p&gt;The SPA never talks to Google. It only starts PKCE against us.&lt;/p&gt;

&lt;p&gt;If there’s no IdP session, you land on &lt;strong&gt;our&lt;/strong&gt; &lt;code&gt;/Account/Login&lt;/code&gt;: password, plus Google/Facebook when those are configured.&lt;/p&gt;

&lt;p&gt;Then:&lt;/p&gt;

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

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

&lt;p&gt;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.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. Token lifetime and refresh
&lt;/h2&gt;

&lt;p&gt;Access tokens last &lt;strong&gt;15 minutes&lt;/strong&gt;. Refresh tokens last &lt;strong&gt;14 days&lt;/strong&gt;. 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.&lt;/p&gt;

&lt;p&gt;We keep all three in &lt;strong&gt;&lt;code&gt;sessionStorage&lt;/code&gt;&lt;/strong&gt;, 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.&lt;/p&gt;

&lt;p&gt;On API &lt;strong&gt;401&lt;/strong&gt;, the SPA posts &lt;code&gt;grant_type=refresh_token&lt;/code&gt; to &lt;code&gt;/connect/token&lt;/code&gt;. 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.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  7. What the SPA does
&lt;/h2&gt;

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

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

&lt;p&gt;Social login: one &lt;strong&gt;Sign in&lt;/strong&gt; button. Google/Facebook show up on the IdP after the redirect.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. What the identity server does
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Authorize:&lt;/strong&gt; 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 (&lt;code&gt;sub&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, …) and an authorization code.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Token:&lt;/strong&gt; 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.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  9. What we keep out of the token
&lt;/h2&gt;

&lt;p&gt;The JWT has identity and scopes. It does &lt;strong&gt;not&lt;/strong&gt; 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.&lt;/p&gt;

&lt;p&gt;So: validate the JWT, read &lt;code&gt;sub&lt;/code&gt;, load roles from the product database, then 200 or 403.&lt;/p&gt;

&lt;p&gt;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.&lt;/p&gt;

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




&lt;h2&gt;
  
  
  10. Logout (three sessions, not one)
&lt;/h2&gt;

&lt;p&gt;With Google in the mix you can have three sessions: Google’s, our IdP cookie, and the SPA’s tokens.&lt;/p&gt;

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

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

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




&lt;p&gt;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.&lt;/p&gt;

&lt;p&gt;&lt;em&gt;(Thanks to a colleague and a language model for helping tidy the English.)&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tags&lt;/strong&gt;: &lt;code&gt;OAuth2&lt;/code&gt; &lt;code&gt;OpenID Connect&lt;/code&gt; &lt;code&gt;PKCE SPA&lt;/code&gt; &lt;code&gt;SSO&lt;/code&gt; &lt;code&gt;Social Login&lt;/code&gt; &lt;code&gt;ASP.NET&lt;/code&gt; &lt;code&gt;Identity&lt;/code&gt; &lt;code&gt;OpenIddict&lt;/code&gt;&lt;/p&gt;

</description>
      <category>oauth</category>
      <category>identityprovider</category>
      <category>sociallogin</category>
      <category>sso</category>
    </item>
  </channel>
</rss>
