How It Works
The PKCE flow, pairwise subjects, and domain-scoped identity explained
The auth flow
When a user clicks "Sign in", here's what happens:
Your app shoo.dev Google
│ │ │
│ 1. startSignIn() │ │
│ generate PKCE bundle │ │
│ store verifier │ │
│ ─────── redirect ───────▶ │ │
│ /authorize? │ │
│ code_challenge=... │ │
│ redirect_uri=... │ │
│ state=... │ 2. redirect to Google │
│ │ ────── redirect ────────▶ │
│ │ │
│ │ 3. user authenticates │
│ │ ◀───── callback ──────── │
│ │ google_code │
│ │ │
│ │ 4. exchange google code │
│ │ get google identity │
│ │ derive pairwise_sub │
│ │ sign id_token (ES256) │
│ │ │
│ ◀──── redirect ────────── │ │
│ ?code=shoo_code │ │
│ &state=... │ │
│ │ │
│ 5. exchangeCode() │ │
│ POST /token │ │
│ code + code_verifier ──▶│ │
│ │ 6. verify PKCE │
│ ◀── { id_token, │ sign JWT │
│ pairwise_sub } ───── │ │
│ │ │
│ 7. store identity │ │
│ redirect to returnTo │ │PKCE
Proof Key for Code Exchange prevents authorization code interception — critical for browser-based apps where there's no client secret.
- The client generates a random
code_verifier(64 characters) and computes its SHA-256 hash as thecode_challenge - The
code_challengeis sent to/authorize - When exchanging the code at
/token, the client sends the originalcode_verifier - Shoo hashes the verifier and checks it matches the stored challenge
This means intercepting the authorization code is useless without the verifier, which never leaves the browser tab (stored in sessionStorage).
Shoo enforces S256 (SHA-256) challenges on every flow. There is no "plain" challenge option.
Pairwise subject
The pairwise_sub is a domain-scoped, stable user identifier. Given the same Google account and the same app origin, you always get the same pairwise_sub. But different origins get different values.
pairwise_sub = HMAC-SHA256(server_secret, google_sub + client_id)This means:
- No cross-app tracking —
app-a.comandapp-b.comsee different identifiers for the same user - No signup required — the
client_idisorigin:{your_origin}, derived automatically - Stable — the same user always gets the same
pairwise_subfor your origin - Opaque — you can't reverse it to get the Google sub
The format is ps_{base64url}, e.g. ps_a1B2c3D4e5F6....
Auto-derived client ID
When you call startSignIn(), Shoo computes:
client_id = "origin:" + new URL(redirect_uri).originFor https://myapp.com/auth/callback, the client_id is origin:https://myapp.com.
This means no client registration step. The first /authorize request from a new origin auto-registers the client. Shoo derives the audience, pairwise salt, and everything else from the origin.
Token signing
Shoo signs id_token with ES256 (ECDSA with the P-256 curve):
- The JWT header includes a
kid(key ID) for rotation - Public keys are published at
/.well-known/jwks.json - OpenID configuration is at
/.well-known/openid-configuration
Always verify the signature, issuer, audience, and expiration on your server. See Server Verification.
Token claims
Every id_token includes:
| Claim | Description |
|---|---|
iss | Issuer — https://shoo.dev |
aud | Audience — origin:{your_origin} |
sub | Subject (same as pairwise_sub) |
pairwise_sub | Domain-scoped unique user ID |
iat | Issued at (unix timestamp) |
exp | Expires at (unix timestamp) |
jti | JWT ID (unique per token) |
With PII consent, these are also included:
| Claim | Description |
|---|---|
email | User's email address |
email_verified | Whether Google has verified the email |
name | User's display name |
picture | URL to profile picture |
Optional PII
Apps can request profile data by passing requestPii: true when starting sign-in. The user sees a consent screen on shoo.dev before any PII is shared.
Consent is per-client and persistent — once a user consents to sharing their email with your origin, they won't be asked again (unless they revoke it).
If no PII is requested, Shoo only asks Google for the openid scope — no email, no profile. This minimizes the data surface.
Revocation and background checks
When a user revokes a site from Shoo account settings, existing local app state should be revalidated by calling POST /session/check with the current id_token as a bearer token.
200 { status: "active" }: keep local session401 { status: "login_required", reason: "revoked" | "expired" | "invalid_token" }: clear local session and prompt sign-in
@shoojs/auth exposes checkSession() and startSessionMonitor() to do this automatically without full-page redirects.
Server endpoints
| Method | Endpoint | Description |
|---|---|---|
GET | /authorize | Start the auth flow |
POST | /token | Exchange code for id_token (CORS-enabled) |
POST | /session/check | Validate current bearer token + revocation status |
GET | /.well-known/jwks.json | Public signing keys |
GET | /.well-known/openid-configuration | OpenID Connect discovery |