SUPER EARLY WIP — USE AT YOUR OWN RISK
shoo
GitHubCOMING SOON

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.

  1. The client generates a random code_verifier (64 characters) and computes its SHA-256 hash as the code_challenge
  2. The code_challenge is sent to /authorize
  3. When exchanging the code at /token, the client sends the original code_verifier
  4. 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 trackingapp-a.com and app-b.com see different identifiers for the same user
  • No signup required — the client_id is origin:{your_origin}, derived automatically
  • Stable — the same user always gets the same pairwise_sub for 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).origin

For 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:

ClaimDescription
issIssuer — https://shoo.dev
audAudience — origin:{your_origin}
subSubject (same as pairwise_sub)
pairwise_subDomain-scoped unique user ID
iatIssued at (unix timestamp)
expExpires at (unix timestamp)
jtiJWT ID (unique per token)

With PII consent, these are also included:

ClaimDescription
emailUser's email address
email_verifiedWhether Google has verified the email
nameUser's display name
pictureURL 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 session
  • 401 { 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

MethodEndpointDescription
GET/authorizeStart the auth flow
POST/tokenExchange code for id_token (CORS-enabled)
POST/session/checkValidate current bearer token + revocation status
GET/.well-known/jwks.jsonPublic signing keys
GET/.well-known/openid-configurationOpenID Connect discovery