Overview
Both Spree APIs — the customer-facing Store API and the back-office Admin API — ship with the same pluggable authentication system. You register a strategy class for a named provider, and the existing login endpoints dispatch to it — no controller patching, no route overrides, no fork. By the end of this guide you’ll have:- A custom strategy that verifies a third-party JWT against a JWKS endpoint
- A user account auto-provisioned on first login, reused on subsequent logins
- A standard Spree-issued JWT + refresh token returned to the client
- Every API endpoint protected by Spree’s own JWT — the third-party token is only used at the login exchange step
Store vs Admin — what changes
The mechanism is identical for both surfaces. A strategy you write works on either side; only the registry you add it to (and a handful of surface-specific details) differ:
Everything else — the strategy class you write, the
BaseStrategy helpers, JWT verification, account provisioning, account linking — is the same. The walkthrough below uses the Store API; each step calls out the one-line Admin swap.
Architecture
The flow below uses the Store API; the Admin API is identical withadmin in the path and aud: admin_api on the issued JWT.
/auth/refresh rotates it via Spree’s own refresh-token mechanism. Your existing CanCanCan rules, current_user, and serializer params just work.
Step 1: Create the Strategy Class
SubclassSpree::Authentication::Strategies::BaseStrategy and implement two methods: provider (a string identifier) and authenticate (returns a Spree::ServiceModule::Result).
app/models/my_app/auth/external_jwt_strategy.rb
What the base class gives you
Spree::Authentication::Strategies::BaseStrategy (in spree_core) exposes a few helpers so your subclass stays small:
find_or_create_user_from_oauth returns the user, not the identity. It creates the Spree::UserIdentity row on first login (mapping provider + uid → user) and reuses it on subsequent logins — so repeat sign-ins land on the same Spree customer.
Step 2: Register the Strategy
Add the strategy to a registry in an initializer. This is the only line that decides which API the provider serves —store_authentication_strategies for customer login, admin_authentication_strategies for staff login. Register with both to allow the same provider on either surface. The key you choose here is what clients send as provider in the login payload.
Spree::Authentication::StrategyRegistry with the same API:
The strategy is instantiated with the surface’s user class automatically —
Spree.user_class from the store registry, Spree.admin_user_class from the admin registry — and your authenticate reads it via the user_class helper, so the same class provisions customers on one side and staff on the other.
Step 3: Call the Exchange Endpoint
The login endpoint is the single dispatcher —/api/v3/store/auth/login for customers, /api/v3/admin/auth/login for staff. The provider field in the body selects the strategy — omit it for built-in email/password, set it to your registered key for everything else. The remaining body fields are whatever your strategy reads from params.
HttpOnly cookie scoped to /api/v3/admin/auth and omits it from the body.
Authorization: Bearer <Spree JWT> on every subsequent call. When the JWT expires (default: 1 hour), it rotates via the refresh endpoint — the Store API takes the refresh token in the body of POST /api/v3/store/auth/refresh; the Admin API drives POST /api/v3/admin/auth/refresh entirely from the cookie (see Admin Auth & Cookie Refresh).
The SDKs wrap the same exchange — @spree/sdk for the Store API, @spree/admin-sdk for the Admin API:
LoginCredentials is a discriminated union — pass { email, password } for the built-in strategy, or { provider, ...customFields } for any strategy you registered.
Account Linking
The naive flow above will create a brand new Spree user the first time a given(provider, uid) is seen — even if a user with the same email already exists from a password signup. If you want same-email-means-same-customer, look up by email first and attach an identity to the existing user:
Logout
Security Notes
A few things worth getting right:- Don’t try to pass the third-party JWT through to protected endpoints. Spree’s
JwtAuthenticationconcern verifiesiss: 'spree'and the expected audience (store_apioradmin_api) with HS256 against the Spree secret — a foreign RS256 token will never validate, and you don’t want it to. The exchange-at-login model is the right one. - JWKS caching and rotation. Cache the JWKS (the example uses a 1-hour TTL) but make sure your loader honors the
kid_not_found: trueoption so that an unrecognizedkidtriggers a refetch. Otherwise key rotation at the IdP locks users out for up to the TTL. - Validate
issandaudclaims. Always. The example passesverify_iss: true, verify_aud: truetoJWT.decode— don’t drop those. - Algorithm pinning. Hard-code
algorithms: ['RS256'](or whatever your IdP uses). Never let the token’s ownalgheader decide — the classicalg: noneand HS-as-RS confusion attacks both exploit lax algorithm selection. - Rate limiting.
POST /auth/loginis rate-limited per IP viaSpree::Api::Config[:rate_limit_login]. Tune it in your app config if needed — the same limit applies to email/password and provider-dispatched logins.
Testing
A strategy is a plain Ruby class — test it in isolation without booting a controller:spec/models/my_app/auth/external_jwt_strategy_spec.rb
Reference
Spree::Authentication::Strategies::BaseStrategy—spree/core/app/models/spree/authentication/strategies/base_strategy.rbSpree::UserIdentity—spree/core/app/models/spree/user_identity.rbSpree::Api::V3::Store::AuthController—spree/api/app/controllers/spree/api/v3/store/auth_controller.rbSpree::Api::V3::Admin::AuthController—spree/api/app/controllers/spree/api/v3/admin/auth_controller.rbSpree::Api::V3::JwtAuthentication—spree/api/app/controllers/concerns/spree/api/v3/jwt_authentication.rb- See also: Staff & Roles for the admin login flow, Customers for storefront auth, and Authentication for
Spree.user_classintegration.

