No results found.

SSO & Sessions

What's actually happening between login click and dashboard render.

SSO & Sessions

Stop treating OIDC as a black box. This page walks through the entire login dance — what’s on the wire, what’s in cookies, and how a single login at WolfieAuth gives the user simultaneous access to every app you’ve registered.

The OIDC handshake (PKCE flow)

┌─────────────┐                                      ┌──────────────────────┐
│   User      │                                      │   WolfieAuth         │
│  Browser    │                                      │ auth.wolfieguard.com │
└──────┬──────┘                                      └───────────┬──────────┘
       │                                                         │
       │  1. GET /auth/login   (your app)                        │
       │ ───────────────────▶                                    │
       │                                                         │
       │  2. 302 → /authorize?response_type=code                 │
       │       &client_id=<yourorg>-<myapp>                      │
       │       &redirect_uri=<your-callback>                     │
       │       &scope=openid profile email org                   │
       │       &code_challenge=<sha256-of-verifier>              │
       │       &state=<csrf-token>                               │
       │ ◀──────────────────────────────────────                 │
       │                                                         │
       │  3. GET /authorize?... (WolfieAuth)                     │
       │ ──────────────────────────────────────▶                 │
       │                                                         │
       │  4. Renders branded login page                          │
       │ ◀──────────────────────────────────────                 │
       │                                                         │
       │  5. POST email + password                               │
       │ ──────────────────────────────────────▶                 │
       │                                                         │
       │  6. 302 → <your-callback>?code=ABC&state=<csrf>         │
       │ ◀──────────────────────────────────────                 │
       │                                                         │
       │  7. GET <your-callback>?code=ABC                        │
       │ ───────────────────▶                                    │
       │                                                         │
       │       (your app POSTs to WolfieAuth /token)             │
       │       grant_type=authorization_code                     │
       │       code=ABC                                          │
       │       code_verifier=<original-PKCE-verifier>            │
       │       client_id=...                                     │
       │       client_secret=...                                 │
       │                                                         │
       │       ◀── { access_token, id_token, refresh_token } ──  │
       │                                                         │
       │       (your app validates id_token signature against    │
       │        /.well-known/jwks.json — no DB hit needed)       │
       │                                                         │
       │       (your app GETs /userinfo with access_token        │
       │        to pull claims into a session cookie)            │
       │                                                         │
       │  8. 302 → /dashboard + Set-Cookie: session=<sealed>     │
       │ ◀───────────────────                                    │
       │                                                         │
       │  9. Subsequent requests carry the session cookie        │
       │       app reads cookie, parses claims, renders          │
       └─────────────────────────────────────────────────────────┘

Key points:

  • PKCE protects against code interception. The code_verifier is generated client-side, never sent until /token exchange. An attacker who steals the URL with ?code=ABC can’t trade it for tokens without the verifier.
  • State is your CSRF defence. Generate a random nonce on /auth/login, stash it in a sealed cookie, compare on callback. Every SDK does this for you.
  • Tokens never go to the browser. Only the sealed session cookie reaches the user’s browser. access_token / id_token / refresh_token stay server-side in the SDK’s session store.
  • No DB hit per request. The id_token is a signed JWT. Validate against the JWKS endpoint at boot time (cached in-process), then trust the contents on every subsequent request.

Authentication factors during the handshake

Step 5 in the diagram above (“POST email + password”) is shorthand. WolfieAuth’s login screen actually offers four factors that the user can mix and match — your app doesn’t see the difference. Whatever combination the user picks, your app receives the same id_token at the end:

  • Password. The classic. Bcrypt hashed at rest, rate-limited per IP and per email. The fallback when nothing else is set up.
  • Passkeys / WebAuthn. Hardware-backed, phishing-resistant. If the user has registered a passkey on the device, the login screen prompts for it instead of (or in addition to) a password. End-to-end passwordless.
  • Magic links. A signed URL emailed to the user. One-shot, expires fast. Doubles as MFA — proving control of the email address counts as a second factor.
  • TOTP. Six-digit codes from any authenticator app (1Password, Bitwarden, Aegis, Authy). Required when the user (or your app’s requireTwoFactor plan flag) enables it. Recovery codes are issued on enrolment for lockout recovery.

The user can register multiple factors and the login screen picks the best available. Recovery codes are single-use and regenerable from /account/security.

What your app sees: the id_token carries an amr (Authentication Methods References) array indicating which factors fired this session — e.g. ["pwd", "mfa"] or ["webauthn"]. If you want to insist on a fresh strong-factor login for a sensitive action (re-auth before changing payment method, for example), redirect to /authorize?prompt=login&max_age=0 and the user gets the password / passkey prompt again.

The SDK seals (encrypts + HMACs) a small JSON object into a single cookie:

{
  "sub":            "clxxx...",
  "email":          "[email protected]",
  "name":           "Pawel Wolfie",
  "role":           "USER",
  "wolfieauth_org_id":     "org_abc",
  "wolfieauth_org_slug":   "acme",
  "wolfieauth_membership_kind":     "MEMBER",
  "wolfieauth_membership_approval": "APPROVED",
  "wolfieauth_role_slug":  "editor",
  "wolfieauth_permissions": ["invoices.read", "invoices.write"],
  "wolfieauth_plans":    [...],
  "wolfieauth_features": ["wolfie-wolfiecrm:ksef_enabled", ...],
  "exp":            1768432200
}

The cookie is httpOnly + secure + sameSite=lax. It cannot be read from JavaScript (no XSS exfiltration). A new login refreshes it. Logout clears it.

Org scope in tokens — multi-tenancy at the auth layer

The wolfieauth_org_id claim is the most important field for any multi-tenant app and the one developers most often miss. Every token is scoped to one org — even if the user belongs to several. This is deliberate: it means your DB queries can use WHERE org_id = $claim straight from the token without ever wondering “but which org is the user looking at right now?”

How the org gets picked:

  1. The user’s homeOrgId is the default — the org they signed up into or were invited as their primary.
  2. The user can switch active org from their account menu (/account/orgs); the next OIDC token mint reflects the new active org.
  3. SDKs accept ?orgId=… on the SSO start URL to force a specific org for that login. Useful when your app already knows which tenant the user is acting on (sub-domain routing, bookmark with org slug in URL).

What flows from the active org:

  • wolfieauth_org_id, wolfieauth_org_slug, wolfieauth_org_parentId (when the org sits under a reseller).
  • wolfieauth_role_slug and wolfieauth_permissions[] — the user’s role in this org. The same user can be admin in org A and viewer in org B.
  • wolfieauth_plans and wolfieauth_features — the plan that this org has subscribed to for your app. Two orgs running your app side-by-side can have different feature sets.
  • wolfieauth_membership_kind (MEMBER / GUEST / SPECIAL_ADMIN) and wolfieauth_membership_approval.

If the user belongs to multiple orgs and your app supports an “org switcher,” call GET /api/auth/me?orgId=<other-org-id> to mint a fresh session against that org without forcing a full OIDC re-handshake. The SDK helpers expose this as switchOrg(orgId) (React, SvelteKit) or Auth::switchOrg($id) (Laravel).

Cross-app SSO

The whole point of having ONE WolfieAuth instance with N registered apps: a user signs in once, their browser holds a cookie at auth.wolfieguard.com, and any other app at any other domain can complete its own OIDC handshake silently (no UI shown).

1. User logs in at app1.com → cookie set at auth.wolfieguard.com
2. User clicks "Sign in" at app2.com (different domain)
3. app2.com redirects to auth.wolfieguard.com/authorize
4. WolfieAuth sees the still-valid SSO cookie, skips the password prompt
5. Redirects back to app2.com/callback with a fresh code
6. app2.com exchanges code for tokens, sets ITS OWN session cookie

The user perceives this as “two clicks and I’m in” instead of “type password again”. The linked_accounts[] claim tells your app which other apps this user has authenticated to:

"linked_accounts": {
  "wolfie-perfex-main": { "external_user_id": "1",  "external_email": "...", "role": "admin" },
  "wolfie-vendor-wolfieguard": { "external_user_id": "42", "external_email": "...", "role": "administrator" }
}

Why this exists: when a user signs in to your app via WolfieAuth, your app likely has its own internal user ID (a row in wp_users, tblstaff, users, whatever). That mapping — “WolfieAuth user clxxx… is internal user 42 in this Perfex install” — is stored once on the WolfieAuth side as a LinkedAccount row, and lands in every subsequent token as linked_accounts[your-client-id].external_user_id. Your app reads it from the JWT claim, looks up the local row by integer ID, and skips the email-matching dance on every login.

If you need to fetch the same mapping for other registered apps (e.g. a CRM that wants to know the WordPress user ID for the same person), you read it from the same linked_accounts map — keyed by client_id. This is how cross-app workflows like “create a Perfex invoice from a WooCommerce order” stitch identities without an extra lookup service.

Logout — RP-initiated end-session

When the user clicks Logout in your app, you must redirect them through WolfieAuth’s /end_session endpoint, not just clear your local cookie. Otherwise the WolfieAuth cookie at auth.wolfieguard.com stays alive and the next login skips the password prompt — confusing and dangerous on shared devices.

// Every SDK does this in its /auth/logout route
GET /auth/logout
   clear local session cookie
   302 to https://auth.wolfieguard.com/session/end?id_token_hint=<id_token>&post_logout_redirect_uri=https://your-app.com/

The user lands back at your homepage, fully logged out from both your app AND WolfieAuth.

Token refresh

If you requested offline_access scope, you got a refresh_token along with the others. Use it to mint fresh access/id tokens without forcing the user to re-authenticate:

POST https://auth.wolfieguard.com/token
  grant_type=refresh_token
  refresh_token=<the-token>
  client_id=...
  client_secret=...

Most SDKs do this automatically — they detect a near-expired session and silently refresh in the background. Refresh tokens themselves rotate (each use gets you a new one), so a stolen refresh token has a tight window before it’s invalidated.

Custom claims with per-app JWT templates

Sometimes the standard claim names don’t match what a downstream service wants — Hasura wants https://hasura.io/jwt/claims, PostgREST wants role, an edge function expects tenant, etc. Instead of forcing every app to re-shape claims at consumption time, you can configure a JWT template on your OidcClient that injects custom claims at token-mint time.

In the admin panel, open /admin/clients/<your-app>/jwt-templates. Drop in a JSON template with Mustache-style interpolation:

{
  "https://hasura.io/jwt/claims": {
    "x-hasura-user-id": "{{sub}}",
    "x-hasura-org-id": "{{wolfieauth_org_id}}",
    "x-hasura-default-role": "{{wolfieauth_role_slug}}",
    "x-hasura-allowed-roles": ["admin", "editor", "viewer"]
  }
}

Every claim available to the user (every wolfieauth_* field, plus standard OIDC ones) can be substituted. There’s a Preview button next to the editor that renders the template against your own current claims — paste in, hit preview, see exactly what your app will receive before saving. Templates are validated for {{…}} balance and size at write-time so a typo doesn’t silently mint broken tokens.

This is also the answer to “I want my app to receive groups: [...] instead of wolfieauth_role_slug” — make a one-line template that aliases the field. The downstream tool then thinks it’s talking to a generic OIDC provider with no WolfieAuth-specific knowledge required.

Approval gate

If the org you log into has requireMembershipApproval: true, the first login lands claims.wolfieauth_membership_approval = "PENDING". Every template SDK middleware automatically bounces these users to a “waiting for approval” screen with a logout button. For hand-rolled flows, check isApproved(claims):

import { isApproved } from '@wolfieauth/sdk-core/oidc/claims';

if (!isApproved(claims)) return redirect('/auth/pending');

The feat/guest-approval workflow at WolfieAuth admin lets ops review the queue and approve as MEMBER or GUEST. After approval, the next OIDC token mint flips wolfieauth_membership_approval to APPROVED and the user gets in.

Beyond the standard flow

A few endpoints exist that most SDKs won’t surface but are worth knowing about for unusual scenarios:

  • Token introspection (POST /token/introspection, RFC 7662) — your backend can ask WolfieAuth “is this access token still valid, and what’s in it?” Useful when an upstream proxy sees a token but doesn’t have the JWKS cached.
  • Token revocation (POST /token/revocation, RFC 7009) — explicitly invalidate an access or refresh token before it expires (e.g. user clicked “log out everywhere”). The session cookie at auth.wolfieguard.com is revoked separately via /session/end.
  • Userinfo endpoint (GET /me with Authorization: Bearer <access_token>) — returns the same claim set as the id_token, scope-gated. Most SDKs hit this once at login to populate the session cookie; you rarely need to call it directly.
  • Public branding endpoint (GET /api/public/clients/<your-client-id>/branding) — returns the theme tokens (colors, logo, copy strings) configured for your app. SDK consumers use this to render a login button styled to match the issuer. No auth required, safe to call from the browser.
  • Discovery (GET /.well-known/openid-configuration) and JWKS (GET /jwks) — the two URLs every OIDC client library auto-discovers. If you’re integrating a library that doesn’t (older ones), these are the URLs you point it at.

If a third-party tool you’re trying to integrate asks for any of the URLs above, all of them live under https://auth.wolfieguard.com/.

What if WolfieAuth is down?

Then nobody can log in. This is a real consequence of using a hosted auth provider, and you should accept it the same way you accept that your app goes down when AWS does. Mitigations:

  • Cache the JWKS aggressively — your app boots even if WolfieAuth is unreachable, as long as it has a cached JWKS.
  • Refresh tokens have long TTLs (default 30d) — existing logged-in users keep working.
  • Use wolfieauth_plans claim, not roundtrips — if your gating reads from the JWT, your app doesn’t care if WolfieAuth is reachable on every request.

Continue reading

Last updated: