docs/developer/how-to/custom-api-authentication.mdx
import { Since } from '/snippets/since.mdx';
<Since version="5.5" />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:
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:
| Store API (customers) | Admin API (staff) | |
|---|---|---|
| Registry | Spree.store_authentication_strategies | Spree.admin_authentication_strategies |
| User class | Spree.user_class | Spree.admin_user_class |
| Login endpoint | POST /api/v3/store/auth/login | POST /api/v3/admin/auth/login |
| Refresh endpoint | POST /api/v3/store/auth/refresh | POST /api/v3/admin/auth/refresh |
| JWT audience | aud: store_api | aud: admin_api |
| Refresh token delivery | returned in the response body | set as an HttpOnly cookie |
| Strategy base class | Spree::Authentication::Strategies::BaseStrategy | (same) |
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.
The flow below uses the Store API; the Admin API is identical with admin in the path and aud: admin_api on the issued JWT.
Client → POST /api/v3/store/auth/login
{ provider: "my_idp", token: "<third-party JWT>" }
│
▼
AuthController looks up the strategy by `provider` key
│
▼
YourStrategy#authenticate
• verifies the JWT against the IdP's JWKS
• finds or creates a Spree::UserIdentity → Spree user
• returns success(user) or failure(message)
│
▼
Spree issues its own JWT (HS256, iss=spree, aud=store_api)
+ a rotatable RefreshToken
│
▼
Client → subsequent calls with `Authorization: Bearer <Spree JWT>`
The third-party JWT proves identity once, at login. After that, the client uses the Spree JWT for everything, and /auth/refresh rotates it via Spree's own refresh-token mechanism. Your existing CanCanCan rules, current_user, and serializer params just work.
Subclass Spree::Authentication::Strategies::BaseStrategy and implement two methods: provider (a string identifier) and authenticate (returns a Spree::ServiceModule::Result).
module MyApp
module Auth
class ExternalJwtStrategy < Spree::Authentication::Strategies::BaseStrategy
PROVIDER = 'external_idp'.freeze
def provider
PROVIDER
end
def authenticate
token = params[:token] || extract_bearer
return failure(Spree.t('api.unauthorized')) if token.blank?
payload = verify_with_jwks(token)
user = find_or_create_user_from_oauth(
provider: PROVIDER,
uid: payload.fetch('sub'),
info: {
email: payload['email'],
first_name: payload['given_name'],
last_name: payload['family_name']
}
)
success(user)
rescue JWT::DecodeError, JWT::ExpiredSignature, JWT::InvalidIssuerError, JWT::InvalidAudError, KeyError => e
failure(Spree.t('api.unauthorized'))
end
private
def verify_with_jwks(token)
jwks_loader = ->(opts) { jwks(force: opts[:kid_not_found]) }
JWT.decode(
token, nil, true,
algorithms: ['RS256'],
iss: ENV.fetch('EXTERNAL_IDP_ISSUER'),
aud: ENV.fetch('EXTERNAL_IDP_AUDIENCE'),
verify_iss: true,
verify_aud: true,
jwks: jwks_loader
).first
end
def jwks(force: false)
Rails.cache.fetch('external_idp:jwks', expires_in: 1.hour, force: force) do
uri = URI(ENV.fetch('EXTERNAL_IDP_JWKS_URL'))
JSON.parse(Net::HTTP.get(uri))
end
end
def extract_bearer
header = request_env['HTTP_AUTHORIZATION'].to_s
header.start_with?('Bearer ') ? header.split(' ', 2).last : nil
end
end
end
end
Spree::Authentication::Strategies::BaseStrategy (in spree_core) exposes a few helpers so your subclass stays small:
| Helper | Purpose |
|---|---|
success(user) | Wrap a user in a successful ServiceModule::Result |
failure(message) | Wrap an error message in a failed result |
find_user_by_email(email) | Lookup against Spree.user_class |
find_or_create_user_from_oauth(provider:, uid:, info:, tokens: {}) | Calls Spree::UserIdentity.find_or_create_from_oauth with the right user_class |
params, request_env, user_class | Reader access to the controller-supplied inputs |
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.
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.
# config/initializers/spree.rb
Rails.application.config.after_initialize do
Spree.store_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
end
# config/initializers/spree.rb
Rails.application.config.after_initialize do
Spree.admin_authentication_strategies.add(:okta, MyApp::Auth::OktaStrategy)
end
# config/initializers/spree.rb
Rails.application.config.after_initialize do
Spree.store_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
Spree.admin_authentication_strategies.add(:external_idp, MyApp::Auth::ExternalJwtStrategy)
end
Both registries are a Spree::Authentication::StrategyRegistry with the same API:
| Method | Purpose |
|---|---|
add(key, strategy_class) | Register a strategy. Overwrites any existing entry under the same key — that's how you swap the built-in :email strategy. |
remove(key) | Unregister a strategy. Idempotent (returns nil if the key was never registered). |
[key] | Look up a registered class. |
key?(key), keys, values, each, to_h | Standard introspection. |
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.
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.
POST /api/v3/store/auth/login
X-Spree-API-Key: pk_your_publishable_key
Content-Type: application/json
{
"provider": "external_idp",
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs..."
}
POST /api/v3/admin/auth/login
X-Spree-API-Key: sk_your_secret_key
Content-Type: application/json
{
"provider": "okta",
"token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIs..."
}
The response is the standard Spree auth payload. The difference is the refresh token: the Store API returns it in the body, while the Admin API sets it as an HttpOnly cookie scoped to /api/v3/admin/auth and omits it from the body.
{
"token": "<Spree HS256 JWT, aud=store_api>",
"refresh_token": "rt_xxxxxxxxxxxx",
"user": { "id": "user_...", "email": "...", "...": "..." }
}
{
"token": "<Spree HS256 JWT, aud=admin_api>",
"user": { "id": "user_...", "email": "...", "...": "..." }
}
The refresh token arrives as a Set-Cookie: spree_admin_refresh_token=…; HttpOnly; Path=/api/v3/admin/auth response header rather than in the body.
From here, the client sends 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:
import { createClient } from '@spree/sdk'
const client = createClient({ baseUrl: 'https://your-store.com', publishableKey: '<pk>' })
const auth = await client.auth.login({
provider: 'external_idp',
token: thirdPartyJwt,
})
import { createAdminClient } from '@spree/admin-sdk'
const client = createAdminClient({ baseUrl: 'https://your-store.com' })
// Refresh token lands in the HttpOnly cookie; only the access token comes back here.
const auth = await client.auth.login({
provider: 'okta',
token: thirdPartyJwt,
})
LoginCredentials is a discriminated union — pass { email, password } for the built-in strategy, or { provider, ...customFields } for any strategy you registered.
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:
def authenticate
payload = verify_with_jwks(params[:token])
email = payload.fetch('email')
user = find_user_by_email(email)
if user
user.identities.find_or_create_by!(provider: PROVIDER, uid: payload['sub']) do |identity|
identity.info = { email: email, name: payload['name'] }
end
else
user = find_or_create_user_from_oauth(
provider: PROVIDER,
uid: payload['sub'],
info: { email: email, first_name: payload['given_name'], last_name: payload['family_name'] }
)
end
success(user)
end
POST /api/v3/store/auth/logout
Content-Type: application/json
{ "refresh_token": "rt_xxxxxxxxxxxx" }
POST /api/v3/admin/auth/logout
Content-Type: application/json
# No body — the refresh token is read from the HttpOnly cookie and cleared.
This revokes the Spree refresh token. The Spree JWT itself remains valid until it expires naturally (short-lived by design — default 1 hour). Spree does not call the IdP's revocation endpoint; if you need single sign-out, do that from the client.
A few things worth getting right:
JwtAuthentication concern verifies iss: 'spree' and the expected audience (store_api or admin_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.kid_not_found: true option so that an unrecognized kid triggers a refetch. Otherwise key rotation at the IdP locks users out for up to the TTL.iss and aud claims. Always. The example passes verify_iss: true, verify_aud: true to JWT.decode — don't drop those.algorithms: ['RS256'] (or whatever your IdP uses). Never let the token's own alg header decide — the classic alg: none and HS-as-RS confusion attacks both exploit lax algorithm selection.POST /auth/login is rate-limited per IP via Spree::Api::Config[:rate_limit_login]. Tune it in your app config if needed — the same limit applies to email/password and provider-dispatched logins.A strategy is a plain Ruby class — test it in isolation without booting a controller:
require 'rails_helper'
RSpec.describe MyApp::Auth::ExternalJwtStrategy do
let(:rsa_private) { OpenSSL::PKey::RSA.generate(2048) }
let(:jwks) { { keys: [JWT::JWK.new(rsa_private).export] } }
let(:token) do
JWT.encode(
{ sub: 'idp-user-123', email: '[email protected]', iss: 'https://idp.example', aud: 'spree' },
rsa_private, 'RS256'
)
end
before do
stub_request(:get, ENV['EXTERNAL_IDP_JWKS_URL']).to_return(body: jwks.to_json)
end
subject(:result) do
described_class.new(
params: { provider: 'external_idp', token: token },
request_env: {}
).authenticate
end
it 'provisions a Spree user on first login' do
expect { result }.to change(Spree.user_class, :count).by(1)
expect(result).to be_success
expect(result.value.email).to eq('[email protected]')
end
it 'reuses the user on subsequent logins' do
described_class.new(params: { token: token }, request_env: {}).authenticate
expect { result }.not_to change(Spree.user_class, :count)
end
it 'fails on an expired token' do
expired = JWT.encode({ sub: 'x', exp: 1.hour.ago.to_i, iss: 'https://idp.example', aud: 'spree' }, rsa_private, 'RS256')
result = described_class.new(params: { token: expired }, request_env: {}).authenticate
expect(result).not_to be_success
end
end
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.rbSpree.user_class integration.