Skip to content
chiltepin

Generated from: “How the login flow works across the SPA, the auth service, and Google.

Login flow

Written by an agent from the skill, validated by chiltepin check, rendered by the renderer — shown as generated, 13 September 2026.

DOCUMENTDRAFT

Login flow

How a user signs in across the SPA, the auth service, and Google.

Login uses OpenID Connect Authorization Code with PKCE. The SPA never sees Google credentials and never holds a Google token. The auth service is the only party that talks to Google's token endpoint, and it is the only party that issues our own session.

SECTION 01 · Note

Assumptions

Note
The request left the stack open, so this document fixes it. The SPA is a React app at app.example.com. The auth service is a Node service at auth.example.com. Google is the only identity provider. The session is a rotating refresh token in an HttpOnly cookie plus a 15-minute access JWT held in SPA memory. Change the nouns here and the blocks below stay valid.

Who talks to whom

Three parties take part. The browser is the SPA's runtime, so the SPA appears as one system. The auth service owns the session store and is the only system with Google client credentials.

SECTION 02 · C4 model

Login system context

C4 · CONTEXT
C4 diagram: 5 elements, 4 relationshipsPERSONUserA person with a Google account.SYSTEMSPAReact app at app.example.com.Starts login and holds theaccess token in memory.SYSTEMAuth serviceNode service at auth.example.com.Exchanges the Google code and issuesour session.DBSession storeRedis. Refresh token familiesand revocations.EXTGoogleOIDC provider. Authenticates theuser and issues the ID token.1234
1clicks Sign in with Google2start, callback, refresh, logout [HTTPS + JSON]3redirect, token exchange, JWKS [OIDC]4reads and writes sessions
LegendPERSONpersonSYSTEMsoftware systemDBdatabaseEXTexternal systemoutside the boundarydata storeuses

Message order on a successful login

The state parameter and the PKCE verifier both live server-side in a short-lived login record keyed by the state cookie. This keeps the SPA free of secrets and lets the auth service reject a callback that it did not start. The redirect from Google lands on the auth service, not the SPA, so the authorization code never enters SPA JavaScript.

SECTION 03 · Sequence

Sign in with Google — happy path

SEQUENCE
Sequence diagram: 15 messages between 3 actorsSPAapp.example.comAuth serviceauth.example.comEXTGoogle1GET /auth/google/start?return_to=/dashboard2create login record: state, PKCE verifier, return_to (TTL 5 min)3302 to Google authorize URL; Set-Cookie login_state (HttpOnly, 5 min)4GET /o/oauth2/v2/auth?client_id&redirect_uri&scope=openid email profile&state&code_challenge5302 to https://auth.example.com/auth/google/callback?code&state6GET /auth/google/callback?code&state (Cookie: login_state)7match state to login record; load PKCE verifier8POST /token (code, code_verifier, client_id, client_secret)9200 { id_token, access_token, expires_in }10GET /oauth2/v3/certs (cached JWKS)11verify id_token signature, iss, aud, exp, nonce, email_verified12upsert user by Google sub; create session family in Redis13302 to /dashboard; Set-Cookie refresh_token (HttpOnly, Secure, SameSite=Lax, 30 d)14POST /auth/refresh (Cookie: refresh_token)15200 { access_token (JWT, 15 min), user }; rotated refresh_token cookie
Legendcallresponsethe answer the caller getsEXTexternal actoractive
Redirect URI: https://auth.example.com/auth/google/callbackScopes: openid email profileAccess token lifetime: 15 minutes

Where a login stops

Every rejection ends at the SPA's /login?error=<code> page so the user gets one place to retry. The auth service deletes the login record on the first callback, so a replayed code or state fails at the first check.

SECTION 04 · Flowchart

Callback checks and their exits

FLOW
Flowchart: 11 stepsCallback arrivesstate matcheslogin_stateGoogle acceptscode + verifier?id_token valid andemail verified?user active andnot blocked?Issue session,redirect to/login?error=state_mismatch/login?error=exchange_failed/login?error=identity_rejected/login?error=account_blocked/login?error=access_denied123456789
1Google returned error=access_denied2yes3no, expired, or reused420054xx or timeout after 3 retries6yes7bad signature, aud, nonce, or email_verified=false8yes9no
Legendstartdecision (diamond)exitnexterror pathhappy path

Session lifecycle

A session is a refresh token family. Each refresh rotates the token; presenting an already-rotated token revokes the whole family, because it means the cookie was copied. The access JWT is not stored anywhere and is not revocable; its 15-minute lifetime bounds the damage of a leak.

SECTION 05 · State machine

Session family lifecycle

STATE
State machine: 6 states, 8 transitionsLOGIN_PENDINGACTIVE12345678
Legendstartstatewaitingendtransitionerror exiterror transition
FromEventGuardTo
1s0GET /auth/google/startLOGIN_PENDING
2LOGIN_PENDINGcallback verifiedstate matches, id_token validACTIVE
3LOGIN_PENDINGlogin record TTL 5 min elapsesABANDONED
4ACTIVEPOST /auth/refresh rotates tokenACTIVE
5ACTIVE30 days without refreshEXPIRED
6ACTIVEPOST /auth/logoutREVOKED
7ACTIVErotated token presented againREVOKED
8ACTIVEadmin blocks userREVOKED

What each party holds

The split below is the security boundary of the design. If a value moves to another column, the threat model changes and this document needs a new revision.

SECTION 06 · Comparison

Credentials and where they live

CredentialHolderStorageLifetimePurpose
Google client_secretAuth serviceSecret manager, env at bootRotated yearlyAuthenticates the auth service to Google's token endpoint
PKCE verifierAuth serviceLogin record in Redis5 minutesProves the callback came from the party that started the login
stateAuth service + browser cookielogin_state cookie (HttpOnly)5 minutesBinds the callback to the browser that started it
Google id_tokenAuth serviceNever stored; verified then discarded1 hour (Google)Proves who the user is; source of sub, email, name
Google access_tokenAuth serviceNever stored1 hour (Google)Unused; we request no Google APIs beyond identity
refresh_tokenBrowserrefresh_token cookie (HttpOnly, Secure, SameSite=Lax)30 days, rotates on useObtains new access tokens; identifies the session family
access_tokenSPAJavaScript memory only15 minutesBearer token on API calls; carries user id and roles

Nothing sensitive is written to localStorage or sessionStorage. A full page reload costs one POST /auth/refresh.

View the Markdown
```meta
title: Login flow
subtitle: How a user signs in across the SPA, the auth service, and Google.
tag: DRAFT
```

Login uses OpenID Connect Authorization Code with PKCE. The SPA never sees Google credentials and never holds a Google token. The auth service is the only party that talks to Google's token endpoint, and it is the only party that issues our own session.

```callout
tone: note
title: Assumptions
body: "The request left the stack open, so this document fixes it. The SPA is a React app at app.example.com. The auth service is a Node service at auth.example.com. Google is the only identity provider. The session is a rotating refresh token in an HttpOnly cookie plus a 15-minute access JWT held in SPA memory. Change the nouns here and the blocks below stay valid."
```

## Who talks to whom

Three parties take part. The browser is the SPA's runtime, so the SPA appears as one system. The auth service owns the session store and is the only system with Google client credentials.

```c4
id: login-context
title: Login system context
level: context
nodes:
  - { id: user, col: 1, row: 1, kind: person, name: User, desc: "A person with a Google account." }
  - { id: spa, col: 2, row: 1, kind: system, name: SPA, desc: "React app at app.example.com. Starts login and holds the access token in memory." }
  - { id: auth, col: 3, row: 1, kind: system, name: Auth service, desc: "Node service at auth.example.com. Exchanges the Google code and issues our session." }
  - { id: sessions, col: 3, row: 2, kind: store, name: Session store, desc: "Redis. Refresh token families and revocations." }
  - { id: google, col: 4, row: 1, kind: external, name: Google, desc: "OIDC provider. Authenticates the user and issues the ID token." }
edges:
  - { from: user, to: spa, label: "clicks Sign in with Google" }
  - { from: spa, to: auth, label: "start, callback, refresh, logout", tech: "HTTPS + JSON" }
  - { from: auth, to: google, label: "redirect, token exchange, JWKS", tech: "OIDC" }
  - { from: auth, to: sessions, label: "reads and writes sessions" }
```

## Message order on a successful login

The state parameter and the PKCE verifier both live server-side in a short-lived login record keyed by the state cookie. This keeps the SPA free of secrets and lets the auth service reject a callback that it did not start. The redirect from Google lands on the auth service, not the SPA, so the authorization code never enters SPA JavaScript.

```sequence
id: login-happy-path
title: Sign in with Google — happy path
actors:
  - { id: SPA, name: SPA, sub: app.example.com }
  - { id: Auth, name: Auth service, sub: auth.example.com }
  - { id: Google, name: Google, external: true }
messages:
  - SPA -> +Auth: "GET /auth/google/start?return_to=/dashboard"
  - Auth -> Auth: "create login record: state, PKCE verifier, return_to (TTL 5 min)"
  - Auth --> -SPA: "302 to Google authorize URL; Set-Cookie login_state (HttpOnly, 5 min)"
  - SPA -> +Google: "GET /o/oauth2/v2/auth?client_id&redirect_uri&scope=openid email profile&state&code_challenge"
  - Google --> -SPA: "302 to https://auth.example.com/auth/google/callback?code&state"
  - SPA -> +Auth: "GET /auth/google/callback?code&state (Cookie: login_state)"
  - Auth -> Auth: "match state to login record; load PKCE verifier"
  - Auth -> +Google: "POST /token (code, code_verifier, client_id, client_secret)"
  - Google --> -Auth: "200 { id_token, access_token, expires_in }"
  - Auth -> Google: "GET /oauth2/v3/certs (cached JWKS)"
  - Auth -> Auth: "verify id_token signature, iss, aud, exp, nonce, email_verified"
  - Auth -> Auth: "upsert user by Google sub; create session family in Redis"
  - Auth --> -SPA: "302 to /dashboard; Set-Cookie refresh_token (HttpOnly, Secure, SameSite=Lax, 30 d)"
  - SPA -> +Auth: "POST /auth/refresh (Cookie: refresh_token)"
  - Auth --> -SPA: "200 { access_token (JWT, 15 min), user }; rotated refresh_token cookie"
foot:
  - { label: Redirect URI, value: "https://auth.example.com/auth/google/callback" }
  - { label: Scopes, value: "openid email profile" }
  - { label: Access token lifetime, value: "15 minutes" }
```

## Where a login stops

Every rejection ends at the SPA's `/login?error=<code>` page so the user gets one place to retry. The auth service deletes the login record on the first callback, so a replayed code or state fails at the first check.

```flow
id: login-failures
title: Callback checks and their exits
dir: LR
nodes:
  - { id: cb, col: 1, row: 1, kind: start, label: "Callback arrives" }
  - { id: state, col: 2, row: 1, kind: decision, label: "state matches login_state cookie?" }
  - { id: exch, col: 3, row: 1, kind: decision, label: "Google accepts code + verifier?" }
  - { id: idt, col: 4, row: 1, kind: decision, label: "id_token valid and email verified?" }
  - { id: allow, col: 5, row: 1, kind: decision, label: "user active and not blocked?" }
  - { id: ok, col: 6, row: 1, kind: end, label: "Issue session, redirect to return_to" }
  - { id: e_state, col: 2, row: 2, kind: end, label: "/login?error=state_mismatch" }
  - { id: e_exch, col: 3, row: 2, kind: end, label: "/login?error=exchange_failed" }
  - { id: e_idt, col: 4, row: 2, kind: end, label: "/login?error=identity_rejected" }
  - { id: e_allow, col: 5, row: 2, kind: end, label: "/login?error=account_blocked" }
  - { id: denied, col: 1, row: 2, kind: end, label: "/login?error=access_denied" }
edges:
  - cb -> state
  - cb -x-> denied: "Google returned error=access_denied"
  - state -> exch: "yes"
  - state -x-> e_state: "no, expired, or reused"
  - exch -> idt: "200"
  - exch -x-> e_exch: "4xx or timeout after 3 retries"
  - idt -> allow: "yes"
  - idt -x-> e_idt: "bad signature, aud, nonce, or email_verified=false"
  - allow -> ok: "yes"
  - allow -x-> e_allow: "no"
```

## Session lifecycle

A session is a refresh token family. Each refresh rotates the token; presenting an already-rotated token revokes the whole family, because it means the cookie was copied. The access JWT is not stored anywhere and is not revocable; its 15-minute lifetime bounds the damage of a leak.

```state
id: session-lifecycle
title: Session family lifecycle
dir: LR
states:
  - { id: s0, col: 1, row: 1, kind: start }
  - { id: pending, col: 2, row: 1, kind: wait, name: LOGIN_PENDING }
  - { id: active, col: 3, row: 1, kind: active, name: ACTIVE }
  - { id: expired, col: 4, row: 1, kind: terminal, name: EXPIRED }
  - { id: revoked, col: 4, row: 2, kind: terminal, name: REVOKED }
  - { id: abandoned, col: 2, row: 2, kind: terminal, name: ABANDONED }
transitions:
  - { from: s0, to: pending, event: "GET /auth/google/start" }
  - { from: pending, to: active, event: "callback verified", guard: "state matches, id_token valid" }
  - { from: pending, to: abandoned, event: "login record TTL 5 min elapses" }
  - { from: active, to: active, event: "POST /auth/refresh rotates token" }
  - { from: active, to: expired, event: "30 days without refresh" }
  - { from: active, to: revoked, event: "POST /auth/logout" }
  - { from: active, to: revoked, event: "rotated token presented again" }
  - { from: active, to: revoked, event: "admin blocks user" }
```

## What each party holds

The split below is the security boundary of the design. If a value moves to another column, the threat model changes and this document needs a new revision.

```table
id: token-inventory
title: Credentials and where they live
columns: [Credential, Holder, Storage, Lifetime, Purpose]
rows:
  - [Google client_secret, Auth service, "Secret manager, env at boot", Rotated yearly, "Authenticates the auth service to Google's token endpoint"]
  - [PKCE verifier, Auth service, "Login record in Redis", 5 minutes, "Proves the callback came from the party that started the login"]
  - [state, "Auth service + browser cookie", "login_state cookie (HttpOnly)", 5 minutes, "Binds the callback to the browser that started it"]
  - [Google id_token, Auth service, "Never stored; verified then discarded", "1 hour (Google)", "Proves who the user is; source of sub, email, name"]
  - [Google access_token, Auth service, "Never stored", "1 hour (Google)", "Unused; we request no Google APIs beyond identity"]
  - [refresh_token, Browser, "refresh_token cookie (HttpOnly, Secure, SameSite=Lax)", "30 days, rotates on use", "Obtains new access tokens; identifies the session family"]
  - [access_token, SPA, "JavaScript memory only", 15 minutes, "Bearer token on API calls; carries user id and roles"]
note: "Nothing sensitive is written to localStorage or sessionStorage. A full page reload costs one POST /auth/refresh."
```