StandardId
A comprehensive authentication engine for Rails applications, built on the security primitives introduced in Rails 8. StandardId provides a complete, secure-by-default solution for identity management, reducing boilerplate and eliminating common security pitfalls.
Features
๐ Complete Authentication System
- Web Authentication: Cookie-based sessions with CSRF protection
- API Authentication: JWT-based tokens for API access
-
Dual Engine Architecture: Separate web (
/) and API (/api) endpoints - Session Management: Browser sessions, device sessions, and service sessions with STI
๐ OAuth 2.0 & OpenID Connect
- Authorization Code Flow: Standard OAuth flow with PKCE support
- Implicit Flow: For single-page applications
- Client Credentials Flow: For service-to-service authentication
- Password Flow: Direct username/password authentication
- Refresh Token Flow: Automatic token renewal
- Social Login: Google OAuth and Apple Sign In integration
๐ฑ Passwordless Authentication
- Email OTP: Send one-time passwords via email
- SMS OTP: Send one-time passwords via SMS
- Configurable Delivery: Host app controls message delivery
- 10-minute Expiry: Secure time-limited codes
๐ข Multi-Tenant Support
- Client Management: OAuth clients with secret rotation
- Polymorphic Ownership: Clients can belong to accounts, organizations, etc.
- Scope Management: Fine-grained permission control
- Redirect URI Validation: Secure callback handling
๐ Advanced Security
- PKCE Support: Proof Key for Code Exchange
- JWT Tokens: Stateless authentication with configurable expiry
- Secret Rotation: Client secret management with audit trail
- Remember Me: Extended session support
- Account Lockout: Protection against brute force attacks
โก Frontend Framework Support
- Inertia.js Integration: Optional support for React, Vue, or Svelte frontends
- Conditional Rendering: Automatically switches between ERB and Inertia based on configuration
- External Redirects: Proper handling of OAuth redirects in SPA contexts
Installation
Add this line to your application's Gemfile:
gem "standard_id"And then execute:
$ bundle installQuick Start
1. Generate Configuration
rails generate standard_id:installThis writes a short config/initializers/standard_id.rb holding only what an
app must decide (account_class_name, plus commented issuer and
c.preset :inertia_web lines). Add a setting only when you change it from the
default. --with-reference writes the annotated initializer instead, with every
commonly changed setting and its default commented out; it is a reference to
read, not a file to keep whole.
2. Configure Your Account Model
# config/initializers/standard_id.rb
StandardId.configure do |config|
config.account_class_name = "User" # or "Account"
config.issuer = "https://your-app.com"
config.login_url = "/login"
end3. Mount the Engines
# config/routes.rb
Rails.application.routes.draw do
mount StandardId::WebEngine, at: "/", as: :standard_id_web
namespace :api do
mount StandardId::ApiEngine, at: "/", as: :standard_id_api
end
end4. Include Authentication in Controllers
# For web controllers
class ApplicationController < ActionController::Base
include StandardId::WebAuthentication
end
# For API controllers
class ApiController < ActionController::API
include StandardId::ApiAuthentication
end5. Action Cable Authentication
- Include in Your Connection Class
module ApplicationCable
class Connection < ActionCable::Connection::Base
include StandardId::CableAuthentication
end
end- Access Current Account in Channels
class ChatChannel < ApplicationCable::Channel
def subscribed
stream_for current_account
end
endConfiguration
Basic Configuration
StandardId.configure do |config|
# Required: Your account model
config.account_class_name = "User"
# OAuth issuer for ID tokens
config.issuer = "https://your-app.com"
# Login URL for redirects
config.login_url = "/login"
# Custom layout for web views
config.web_layout = "application"
# Inertia.js support (see Inertia.js Integration section below)
# config.use_inertia = true
# config.inertia_component_namespace = "auth"
# Session lifetimes
# config.session.browser_session_lifetime = 86400 # 24 hours (web sessions)
# config.session.browser_session_remember_me_lifetime = 2_592_000 # 30 days (remember me cookies)
# config.session.device_session_lifetime = 2_592_000 # 30 days (API device sessions)
# config.session.service_session_lifetime = 7_776_000 # 90 days (service-to-service sessions)
# Subset configuration
# config.password.minimum_length = 12
# config.password.require_special_chars = true
# config.passwordless.code_ttl = 600
# config.oauth.default_token_lifetime = 3600
# config.oauth.refresh_token_lifetime = 2_592_000
# config.oauth.token_lifetimes = {
# password: 8.hours.to_i,
# implicit: 15.minutes.to_i
# }
enddefault_token_lifetime is applied to every OAuth grant unless you override it in oauth.token_lifetimes. Keys map to OAuth grant types (for example :password, :client_credentials, :refresh_token) and should return durations in seconds. Non-token endpoint flows such as the implicit flow can be customized with their symbol key (e.g. :implicit). Refresh tokens can be tuned separately through oauth.refresh_token_lifetime.
OAuth Hardening Options
Two spec-conformance settings default to the historical (looser) behaviour so that upgrading the gem never changes what a live client sees. New deployments should turn both on.
StandardId.configure do |config|
# RFC 6749 ยง4.1.3 โ reject a token exchange that omits redirect_uri when the
# authorization request included one. Default: false (accepted, with a
# warning logged naming the client_id). Watch your logs for that warning;
# once it stops appearing, flip this to true.
config.oauth.strict_redirect_uri_matching = true
# RFC 7009 โ how much POST /oauth/revoke revokes.
# :account (default) revokes EVERY active DeviceSession for the token's
# subject, whichever token was presented โ one client's logout signs the
# account out everywhere.
# :grant revokes only the authorization grant behind the presented token:
# the refresh-token family its `jti` resolves to, plus that grant's
# Session when one is linked.
config.oauth.revocation_scope = :grant
endTwo further settings close the resource-owner password grant, or harden it, for hosts whose email+password sign-in lives in their own API:
StandardId.configure do |config|
# Refuse grant types you do not want on the token endpoint. A disabled grant
# answers exactly like an unknown one (400 unsupported_grant_type, before the
# flow's params are read) and is dropped from `grant_types_supported` in both
# discovery documents. Values are the grant_type strings.
config.oauth.disabled_grant_types = %w[password]
# If the password grant stays enabled: refuse credentials whose identifier is
# not verified, with the same invalid_grant answer as a wrong password, and
# publish AUTHENTICATION_FAILED with error_code "identifier_unverified".
config.oauth.password_grant_requires_verified_identifier = true
endRefresh tokens are bound to the ClientApplication their grant resolved
(standard_id_refresh_tokens.client_application_id, since 0.48). Deactivating
the application suspends every refresh token it was issued โ the grant answers
the generic invalid_grant โ and reactivating it restores them (a replayed,
already-rotated token still trips reuse detection meanwhile and revokes its
family, as it always did); deleting the application ends them for good, which
is what makes RFC 7592 DELETE on a dynamically registered client an actual
revocation. A token whose client_id
is not a registered application (social sign-in refreshes as the provider's
name) has no application to share a fate with and is left alone. There is no
setting: this is how the grant behaves once the migration has run.
Under :grant, revoking a stateless access token is a no-op โ this engine
has no per-access-token store, so it cannot invalidate one before its exp
(the response is still 200, per RFC 7009 ยง2.2). Clients that need revocation
to bite must present the refresh token. token_type_hint is accepted and
unused under both settings: the lookup is by jti, which is type-agnostic, and
a wrong hint must not change the outcome.
ServiceSessions are never touched by /oauth/revoke under either setting โ
machine credentials have their own lifecycle.
Token Introspection (RFC 7662)
POST /oauth/introspect is off by default (config.oauth.introspection_enabled);
once on, confidential clients can ask whether a token is live. The gem checks
the signature and expiry, and for a refresh token also its row, its parent
session and the ClientApplication it is bound to. Two settings let the host
add what only it knows:
StandardId.configure do |config|
config.oauth.introspection_enabled = true
# Run after the gem's checks pass. Takes any subset of payload:,
# refresh_token: (nil for an access token) and request:. Falsy โ or an
# exception, which is logged โ renders {"active": false}.
config.oauth.introspection_liveness = ->(payload:) {
next true if payload[:gid].blank? # a token with no host row to check (client_credentials)
profile = GlobalID::Locator.locate(payload[:gid])
profile.present? && profile.active?
rescue ActiveRecord::RecordNotFound # the row was deleted: inactive, and no log line needed
false
}
# Extra claims echoed VERBATIM on an ACTIVE response, beyond the RFC 7662
# members โ list scalar claims. Absent claims are omitted; the standard
# members cannot be overridden.
config.oauth.introspection_claims = %w[profile_type gid]
endAccess tokens are stateless, so a revoked session's access token still
introspects as active until its exp; keep access_token_lifetime short.
Authenticating Opaque Session Tokens
Session.by_token is a lookup, not authentication: it matches the SHA256
lookup_hash (an index key). The credential is the BCrypt token_digest.
Use authenticate_by_token, which does both and compares in constant time:
session = StandardId::Session.api_compatible.active.authenticate_by_token(token)
raise Unauthorized if session.nil?It honours the current scope, returns nil (never raises) for a blank,
unknown, mismatched, or malformed-digest token, and has an instance-level
counterpart, session.authenticate_token(token).
Minting Tokens Outside the Token Endpoint
StandardId::TokenMinter mints a token for an account the host has already
authenticated by its own means โ an operator console, a service-to-service
credential, a self-service "mint me a token" tool โ through the same pipeline
/oauth/token uses, so it carries the same claims, obeys the same audience โ
profile binding and account-status guards, and publishes the same
OAUTH_TOKEN_ISSUING / OAUTH_TOKEN_ISSUED events:
result = StandardId::TokenMinter.mint(
account: account,
audience: "harness",
scopes: "profile permissions",
grant_type: "harness.mint", # free-form; lands in the JWT and the audit event
claims: { permissions: profile.harness_scopes }, # host extras; reserved keys are dropped
expires_in: 60.minutes,
refresh_token: false # true also persists and returns a refresh token
)
result.access_token
result.jti # what OAUTH_TOKEN_ISSUED carriedscope_claims / claim_resolvers and custom_claims run exactly as for the
endpoint (the resolvers see client: as the ClientApplication named by
client_id:, or nil, and request: as nil for a sessionless mint). Pass
request: to let session.session_type_resolver materialise a session;
without it the mint is sessionless, like client_credentials. A refreshable
token needs client_id: (the refresh grant matches on it) and, under a
login_method_policy, auth_method: / auth_provider: so the refresh is not
judged as :unspecified. expires_in: must be positive. The grant is not
reachable from /oauth/token.
Custom Token Claims
You can add additional JWT claims for any token issued through the OAuth token endpoint by mapping scopes to claim names and providing callbacks to resolve each claim. Scopes listed in oauth.scope_claims are evaluated against the requested token scopes; when a scope matches, every claim listed for that scope is resolved via the callable defined in oauth.claim_resolvers.
StandardId.configure do |config|
config.oauth.scope_claims = {
profile: %i[email display_name]
}
config.oauth.claim_resolvers = {
email: ->(account:) { account.email },
display_name: ->(account:, client:) {
"#{account.name} for #{client.client_id}"
}
}
endResolvers receive keyword arguments with the context containing client, account, and request, so you can reference only what you need. This lets you, for example, pull organization info off the client application or decorate claims with account attributes.
Per-Audience Scope Vocabulary
Declare, per audience, the set of scopes a client targeting that audience may request and be granted. This lets each audience expose its own MCP scope vocabulary rather than sharing one flat scopes_supported list. It is the scope-side counterpart to the audience_profile_types binding: that binds an audience to a required profile, this binds it to a grantable scope vocabulary.
StandardId.configure do |config|
config.oauth.audience_scopes = {
"harness" => %w[mcp mcp:read mcp:eval:run mcp:prompt:write],
"companion_kit" => %w[mcp mcp:read],
"admin_kit" => %w[mcp mcp:read mcp:admin]
}
# Optional: compute a vocabulary dynamically (e.g. per-client entitlements).
# Return an Array<String>, or nil to fall back to the static map above.
config.oauth.audience_scope_resolver = ->(audience:, client:, configured_scopes:) {
Entitlements.mcp_scopes_for(client, audience) || configured_scopes
}
endStandardId::Oauth::AudienceScopeResolver reads this config:
R = StandardId::Oauth::AudienceScopeResolver
R.scopes_for(audience: "companion_kit") # => ["mcp", "mcp:read"]
R.permits?(scope: "mcp:admin", audience: "companion_kit") # => false
R.filter(requested: %w[mcp mcp:admin], audience: "companion_kit") # => ["mcp"] (narrows, never raises)
R.assert!(requested: %w[mcp mcp:admin], audience: "companion_kit") # => raises InvalidScopeErrorAn unconfigured audience fails open โ filter and assert! pass the requested scopes through unchanged โ so an app that does not model per-audience vocabularies sees no behaviour change; narrowing only bites once an audience has an explicit vocabulary.
Serving a Protected Resource (RFC 9728)
A resource server โ an MCP endpoint, a service API โ needs three things: the bearer check, a 401/403 that tells the client where the authorization server is, and the RFC 9728 metadata document that 401 points at. The gem provides all three.
# config/initializers/standard_id.rb
StandardId.configure do |config|
config.oauth.audience_scopes = { "mcp" => %w[mcp mcp:read] }
config.oauth.protected_resources = {
"mcp" => { path: "/mcp" } # scopes_supported defaults to audience_scopes["mcp"]
}
end
# config/routes.rb
standard_id_protected_resource_routes # /.well-known/oauth-protected-resource(/mcp)
# app/controllers/mcp/base_controller.rb
class Mcp::BaseController < ActionController::API
include StandardId::ResourceServer
protected_resource "mcp", scopes: %w[mcp]
endprotected_resource verifies the access token, requires its aud to carry the
audience (with the audience โ profile-type binding when configured) and one of
scopes: when given. A missing, expired, revoked or invalid token, or a deactivated
or locked account, is 401 invalid_token; a token for another audience or without a
required scope is 403 insufficient_scope. Every refusal carries
WWW-Authenticate: Bearer realm="mcp", error=โฆ, resource_metadata="<origin>/.well-known/oauth-protected-resource/mcp",
which is how an MCP client discovers the authorization server and starts the
authorization-code + PKCE flow. The document itself is built by
StandardId::Oauth::ProtectedResourceDocument from the entry: path (required, or
an absolute URL on another origin), scopes_supported, authorization_servers
(default [issuer]), bearer_methods_supported (default ["header"]) and any other
RFC 9728 member; callables receive { origin:, audience:, issuer:, request: } and
nil removes a member. Rate limiting and resolving the token's claims to a profile
stay in the host controller.
Two things to know. The document's resource and the challenge URL are built from
the request origin, as RFC 9728 ยง3.3 requires, so pin Host (config.hosts, or at
the proxy): the document is served with Cache-Control: public. And a token minted
for an audience outside config.oauth.allowed_audiences is refused at decode time
as 401 invalid_token, before the concern sees it; the 403 insufficient_scope is
for a token minted for another allowed audience.
Dynamic Client Registration (RFC 7591 / RFC 7592)
POST /oauth/register is off by default (config.oauth.dynamic_registration_enabled).
When on, a self-registering client (an MCP connector, say) chooses only what a client
should: its name, redirect URIs, grant and response types, and its
token_endpoint_auth_method from config.oauth.dynamic_registration_auth_methods
(%w[none] to accept public PKCE clients only). Everything else is yours to pin:
StandardId.configure do |config|
config.oauth.dynamic_registration_enabled = true
config.oauth.dynamic_registration_owner = ->(metadata:) { Oauth::DynamicRegistration.create!(client_metadata: metadata) }
config.oauth.dynamic_registration_attributes = {
scopes: %w[mcp mcp:read], # whatever scope was requested
metadata: { "audience" => "mcp", "dcr" => true }, # what AudienceResolver reads at /authorize
access_token_lifetime: 3600
}
enddynamic_registration_attributes may also be a callable taking metadata: and/or
request:. Pinnable keys: scopes, metadata, access_token_lifetime,
authorization_code_lifetime, require_consent, description. With no scopes
pinned, a pinned metadata["audience"] that has an audience_scopes vocabulary
gives the client that vocabulary. Every registered client is PKCE/S256 and
consent-gated unless you pin require_consent: false; do that knowingly, because a
consent-free client never records a ClientGrant, so the stale-client purge below
cannot tell an abandoned registration from one in daily use and reaps such clients
only once they deregister themselves. The per-IP registration limit is then the only
defence against bot-created rows.
Once migration 20261020120000 has run, the registration response also carries
registration_client_uri and a one-time registration_access_token (stored as a
digest), and the RFC 7592 client-configuration endpoint is live:
GET/PUT/DELETE /oauth/register/:client_id, authenticated by that token as a
bearer credential. PUT changes the client-shaped members under the registration
rules (never the pinned attributes or the client type); DELETE deactivates the
client and revokes its refresh tokens. An unknown client, a wrong token and a
deregistered client are the same 401. StandardId::PurgeStaleDynamicClientsJob
(in CleanupAllJob, standard_id:cleanup:dynamic_clients, scheduled daily by the
install generator) deletes deregistered clients and clients nobody ever authorized
after a 7-day grace window.
Social Login Setup
Need the account ready before the token is minted โ a profile the requested
audience is bound to, a region check, a first-touch record? Set
config.social.before_mint; it runs on the API callback after the account is
found or created and before SocialFlow mints, and a raise refuses the
sign-in through the callback's normal cleanup (nothing minted, staged link
dropped, a just-created account removed):
config.social.before_mint = ->(account:, audience:, params:) {
DeviceUserProfiles::Ensure.call(account:, region: params["region"]) if audience == "companion_kit"
}The hook receives any subset of account:, provider:, social_info:,
provider_tokens:, audience:, scopes:, params: (the forwarded request
params; the reserved OAuth ones such as audience and scope are stripped,
which is why those two arrive as their own keywords) and request:. It runs
before the login-method policy and the audience โ profile binding, so if either
refuses the sign-in on an account that already existed, what the hook wrote
stays: make it idempotent. It covers the API callback only; the WebEngine's
social callback does not run it.
Two more knobs on the same callback. config.social.before_account runs before
the account is found or created (same keywords minus account:), for a refusal
that must leave no account row and fire no account events โ and no event of its
own, so audit it in the hook. config.social.completion_before_mint = true
publishes SOCIAL_AUTH_COMPLETED after before_mint and before the mint (then
reloads the account), for a completion subscriber that creates what the mint
depends on; if the mint then refuses on an account that already existed, the
subscriber's writes stay, so make it idempotent. Either hook can refuse in the
host's own error envelope:
config.social.before_account = ->(social_info:, request:) {
if RegionBlock.blocked?(request)
raise StandardId::RequestRefused.new(status: :forbidden,
body: { error: { code: "REGION_BLOCKED", message: "Sign-in is not available here" } })
end
}The social.google_* and social.apple_* fields are declared by the provider
plugin gems, not by standard_id itself โ add standard_id-google and/or
standard_id-apple to your Gemfile first. Writing a field whose gem is absent
raises StandardId::ConfigurationError ("Unknown field ... for scope
social"), because nothing declared it.
With the gem present, a plain initializer is correct: since 0.33.0 standard_id
declares every loaded provider's fields before :load_config_initializers, so
these writes happen after the schema knows about them. On 0.32.0 and earlier the
fields were declared only from the plugin Railtie's after_initialize, so the
same code raised and apps wrapped the writes in
Rails.application.config.after_initialize { ... }. That wrapper is no longer
necessary; existing ones keep working unchanged.
ENV defaults (0.42+). Every provider field you never assign falls back to the ENV variable named after it, upper-cased. With these set, the provider credentials need no lines in your initializer at all:
| Field | ENV variable |
|---|---|
google_client_id / google_client_secret
|
GOOGLE_CLIENT_ID / GOOGLE_CLIENT_SECRET
|
apple_client_id (web Services ID) |
APPLE_CLIENT_ID |
apple_mobile_client_id (native bundle ID) |
APPLE_MOBILE_CLIENT_ID |
apple_private_key (.p8 PEM, newlines intact) |
APPLE_PRIVATE_KEY |
apple_key_id / apple_team_id
|
APPLE_KEY_ID / APPLE_TEAM_ID
|
Assign a field only to read it from somewhere else โ a differently named
variable, Rails credentials. An explicit assignment, even of nil, always wins
over the ENV fallback.
The fallback is resolved once, when the config is built at boot โ setting
ENV later (in a spec, say) changes nothing by itself. Two helpers (0.43+):
-
StandardId.config.social.assigned?(:apple_client_id)is true only when the host assigned the field. (key?is true for every declared field, assigned or not, because defaults are written into the scope at build time.) -
In specs,
with_provider_env(fromrequire "standard_id/testing") sets the variables, re-resolves every unassigned field, runs the block and restores both โ so "set the env var โ provider enabled" is testable:RSpec.describe "Apple sign-in" do include StandardId::Testing::ConfigHelpers it "turns on with APPLE_CLIENT_ID" do with_provider_env("APPLE_CLIENT_ID" => "com.example.web") do expect(StandardId.social_provider_enabled?(:apple)).to be(true) end end end
A field your initializer assigns explicitly ignores ENV, inside the helper too.
StandardId.configure do |config|
# Only needed when not using the canonical ENV names above:
config.social.apple_private_key = Rails.application.credentials.dig(:apple, :private_key)
config.social.allowed_redirect_url_prefixes = ["sidekicklabs://"]
# Optional: adjust which attributes are persisted during social signup
config.social.social_account_attributes = ->(social_info:, provider:) {
{
email: social_info[:email],
name: social_info[:name] || social_info[:given_name]
}
}
endsocial_info is an indifferent-access hash containing at least email, name, and provider_id.
Is a provider on? A provider is enabled when its client ID is present. Ask StandardId rather than checking the client ID yourself:
StandardId.social_provider_enabled?(:google) # => false when the plugin is absent, too
StandardId.enabled_social_providers # => { "google" => StandardId::Providers::Google }
StandardId::Providers::Apple.configuration_errors
# => ["apple_private_key is required when apple_client_id is set"]Boot-time check. Once every plugin has registered, StandardId checks each enabled provider for missing required fields โ for example an Apple client ID without the private key, whose sign-in flow would start fine and then fail at the callback, after the user had already authenticated with Apple. By default it logs a warning. To fail the deploy instead:
config.social.provider_misconfiguration = :raise # raises in production, warns elsewhereWhich fields are required is declared by each plugin (required: true, see
Writing a Provider Plugin), so the check covers a
provider once its plugin release declares them.
To handle social login completion (e.g., for analytics or audit logging), subscribe to the SOCIAL_AUTH_COMPLETED event:
# config/initializers/standard_id_events.rb
StandardId::Events.subscribe(StandardId::Events::SOCIAL_AUTH_COMPLETED) do |event|
Analytics.track_social_login(
provider: event[:provider],
account_id: event[:account].id,
tokens: event[:tokens]
)
endInertia.js Integration
StandardId supports Inertia.js for modern React, Vue, or Svelte frontends. When enabled, web controllers render Inertia components instead of ERB views.
Setup
- Add the
inertia_railsgem to your Gemfile:
gem "inertia_rails"- Enable Inertia in your StandardId configuration:
StandardId.configure do |config|
config.use_inertia = true
config.inertia_component_namespace = "auth" # Optional, defaults to "standard_id"
endOr start from the :inertia_web preset, which bundles the settings an
Inertia app with passwordless or social sign-in usually flips:
StandardId.configure do |config|
config.preset :inertia_web
# use_inertia = true, alias_current_user = true,
# web.signup / web.password_login / web.password_reset = false,
# oauth.discovery_endpoint_base = :request
config.web.passwordless_login = true # not part of the preset: your call
endA preset fills only the settings you haven't assigned, so an explicit
assignment wins whether it comes before or after preset. An unknown name
raises ArgumentError. The public defaults are unchanged.
- Create the corresponding frontend components. The component path follows the pattern:
{namespace}/{ControllerName}/{action}
For example, with inertia_component_namespace = "auth":
- Login page:
pages/auth/login/show.tsx - Signup page:
pages/auth/signup/show.tsx
Example Component (React)
// frontend/pages/auth/login/show.tsx
import { useForm } from '@inertiajs/react'
interface Props {
redirect_uri: string
connection: string | null
flash: { notice?: string; alert?: string }
social_providers: { google_enabled: boolean; apple_enabled: boolean }
enabled_social_providers: string[]
}
export default function LoginShow({ redirect_uri, flash, social_providers }: Props) {
const { data, setData, post, processing } = useForm({
'login[email]': '',
'login[password]': '',
'login[remember_me]': false,
redirect_uri,
})
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
post('/login')
}
const handleSocialLogin = (connection: string) => {
post('/login', { data: { connection, redirect_uri } })
}
return (
<div className="login-container">
{flash.alert && <div className="alert alert-error">{flash.alert}</div>}
{flash.notice && <div className="alert alert-success">{flash.notice}</div>}
<form onSubmit={handleSubmit}>
<div>
<label htmlFor="email">Email</label>
<input
id="email"
type="email"
value={data['login[email]']}
onChange={e => setData('login[email]', e.target.value)}
required
/>
</div>
<div>
<label htmlFor="password">Password</label>
<input
id="password"
type="password"
value={data['login[password]']}
onChange={e => setData('login[password]', e.target.value)}
required
/>
</div>
<div>
<label>
<input
type="checkbox"
checked={data['login[remember_me]'] as boolean}
onChange={e => setData('login[remember_me]', e.target.checked)}
/>
Remember me
</label>
</div>
<button type="submit" disabled={processing}>
{processing ? 'Signing in...' : 'Sign In'}
</button>
</form>
{(social_providers.google_enabled || social_providers.apple_enabled) && (
<div className="social-login">
<p>Or continue with</p>
{social_providers.google_enabled && (
<button type="button" onClick={() => handleSocialLogin('google')}>
Sign in with Google
</button>
)}
{social_providers.apple_enabled && (
<button type="button" onClick={() => handleSocialLogin('apple')}>
Sign in with Apple
</button>
)}
</div>
)}
</div>
)
}Note: The
useFormhook from@inertiajs/reactautomatically handles CSRF tokens. When you callpost(),put(),patch(), ordelete(), Inertia reads the CSRF token from the<meta name="csrf-token">tag in your layout and includes it in the request headers.
Props Passed to Components
Authentication pages receive the following props:
| Prop | Type | Description |
|---|---|---|
redirect_uri |
string |
URL to redirect to after authentication |
connection |
string | null |
Social provider connection (if any) |
flash |
{ notice?: string, alert?: string } |
Flash messages |
social_providers |
{ [name]_enabled: boolean } |
One flag per registered provider. google_enabled and apple_enabled are always present (false when the plugin is absent) |
enabled_social_providers |
string[] |
Names of the enabled providers, e.g. ["google"] โ iterate this rather than hard-coding provider names |
errors |
object |
Validation errors (on form submission failures) |
Using Authentication in Host App Controllers
You can use the authenticate_account! method in your own controllers to require authentication with Inertia-compatible redirects:
class DashboardController < ApplicationController
include StandardId::WebAuthentication
before_action :authenticate_account!
def show
# Only authenticated users can access this
end
endThis will redirect unauthenticated users to the login page using inertia_location for Inertia requests, ensuring proper SPA navigation.
Passwordless Code Delivery
Subscribe to the PASSWORDLESS_CODE_GENERATED event to deliver OTP codes:
# config/initializers/standard_id_events.rb
StandardId::Events.subscribe(StandardId::Events::PASSWORDLESS_CODE_GENERATED) do |event|
next if event[:skip_sender] # Otp.issue(delivery: :manual) โ the caller delivers
case event[:channel]
when "email"
UserMailer.send_code(event[:identifier], event[:code_challenge].code).deliver_now
when "sms"
SmsService.send_code(event[:identifier], event[:code_challenge].code)
end
endSet c.passwordless.delivery = :custom (the default) so the engine's built-in
PasswordlessMailer stays out of the way; with :built_in the engine emails
the code itself โ otp_email ("Your sign-in code") for the "authentication"
realm and verification_email ("Your verification code") for every other realm
(WebEngine verify_email, Otp.issue contact verification / step-up). There is
no built-in SMS delivery. The copy is in config/locales/en.yml under
standard_id.passwordless_mailer.{otp_email,verification_email}.* (subject,
greeting, intro, expiry, ignore, footer); override any key in your own
locale files, or the templates in app/views/standard_id/passwordless_mailer/.
An assigned c.passwordless.mailer_subject still sets the sign-in subject. The subscriber runs synchronously inside the request, so
I18n.locale, Current.* and the like are still available.
The built-in mailers inherit from ActionMailer::Base. Set
c.mailer_parent_class = "ApplicationMailer" to have them inherit your mailer
base instead, with its layout, helpers, default_url_options and locale
handling. It is read when the gem's mailers load, so set it in an initializer.
The sender still comes from c.passwordless.mailer_from and
c.reset_password.mailer_from, which every gem message passes explicitly, so a
parent's default from: does not apply to them.
Event payload includes:
-
channel-"email"or"sms" -
identifier- The email address or phone number -
code_challenge- The code challenge object with.codemethod -
expires_at- When the code expires -
realm- The OTP realm ("authentication"for sign-in) -
skip_sender-trueforOtp.issue(delivery: :manual); don't deliver -
delivery- TheOtp.issuedelivery mode (:built_in/:custom/:manual),nilotherwise
Note:
passwordless_email_sender/passwordless_sms_senderwere removed in 0.43; see the Migration Guide.
Starting a passwordless login from your own controller
To send the code from your own controller (a sign-up form, an invite page) and
let the WebEngine's verify page finish the sign-in, include
StandardId::PasswordlessFlow and call start_passwordless_login. It sends the
code and stores the signed hand-off the verify page requires, then you redirect
there through the engine's mounted helper, which includes the mount path:
class Onboarding::SignupsController < ApplicationController
include StandardId::PasswordlessFlow
def create
start_passwordless_login(username: params[:email], return_to: welcome_path)
redirect_to standard_id_web.login_verify_path, status: :see_other
rescue StandardId::InvalidRequestError => e
redirect_to new_signup_path, alert: e.message
end
endconnection: defaults to c.passwordless.connection. return_to: becomes the
post-sign-in destination, still subject to the verify page's safe-redirect check.
Nothing is stored when the code cannot be sent.
Using OTP for non-authentication flows
The same hardened OTP machinery (enumeration defense, atomic attempt tracking, pessimistic locking, bypass_code hook) is exposed as a public primitive via StandardId::Otp. Use it when you need one-time codes for purposes other than authentication โ contact verification widgets, step-up challenges, custom confirmation flows, etc.
# Issue a code โ caller handles delivery
result = StandardId::Otp.issue(
realm: "widget_contact_verification",
target: "user@example.com",
channel: :email,
request: request,
delivery: :manual
)
MyMailer.widget_otp(result.challenge.target, result.code).deliver_later
# Verify the code
result = StandardId::Otp.verify(
realm: "widget_contact_verification",
target: params[:email],
channel: :email,
code: params[:otp],
request: request
)
if result.success?
# Code is valid โ proceed with the action.
else
case result.error_code
when :not_found, :invalid_code then render_error("Invalid code")
when :expired then render_error("Code expired")
when :max_attempts then render_error("Too many attempts")
end
endDelivery modes for Otp.issue:
| Mode | Behavior |
|---|---|
:built_in (default) |
Follows c.passwordless.delivery: the engine's PasswordlessMailer (email only) when that is :built_in, otherwise your PASSWORDLESS_CODE_GENERATED subscriber. |
:custom |
Your PASSWORDLESS_CODE_GENERATED subscriber delivers (payload delivery: :custom); the engine mailer never does, even under a global :built_in. Nothing is sent unless you subscribe. |
:manual |
Skips delivery (payload skip_sender: true); returns the raw code on the result for the caller to deliver. |
Realm isolation. realm: is a free-form string that partitions challenges by purpose. A code issued for realm "widget_contact_verification" cannot be used to verify against realm "authentication" (or any other realm) โ even for the same target. Choose a stable string per flow.
Bypass code (E2E testing). When StandardId.config.passwordless.bypass_code is set (and the deploy is not production), Otp.verify accepts the bypass code for any realm. This replaces per-app bypass ENV checks and works consistently across Otp.verify and the built-in passwordless login flow. Never set bypass_code in production โ it will raise if you try.
"Production" is decided by StandardId::AppEnvironment, the same rule every
check in the gem uses: APP_ENVIRONMENT when it is set, where only staging,
preview, development and test count as non-production (any other value,
including a typo, is production), else Rails.env.production?. So a staging box
running RAILS_ENV=production with APP_ENVIRONMENT=staging is not
production, and needs no extra wiring:
c.passwordless.bypass_code = ENV["STANDARD_ID_BYPASS_CODE"] unless StandardId::AppEnvironment.production?production_env_detector (a callable taking no arguments, returning a boolean)
still overrides that, for an app with a different discriminator.
Back-compat. The existing passwordless authentication flow continues to work unchanged. Otp.issue/Otp.verify are a new addition โ you can migrate direct CodeChallenge.create! calls at your own pace.
Event System
StandardId emits events throughout the authentication lifecycle using ActiveSupport::Notifications. This enables decoupled handling of cross-cutting concerns like logging, analytics, audit trails, and webhooks.
Enabling Event Logging
Enable the built-in structured logging subscriber:
StandardId.configure do |config|
config.events.enable_logging = true
endThis outputs JSON-structured logs for all authentication events:
{
"subject": "standard_id.authentication.attempt.succeeded",
"severity": "info",
"duration": 50.25,
"account_id": 123,
"auth_method": "password",
"ip_address": "192.168.1.1"
}Available Events
Every StandardId event automatically carries tracing metadata (event_id, timestamp, and request-scoped fields like request_id, ip_address, user_agent, current_account when available). The table below lists the domain-specific payload fields and when each event fires.
| Category | Event | Payload fields | When emitted |
|---|---|---|---|
| Authentication | authentication.attempt.started |
account_lookup, auth_method
|
Before credential validation begins |
authentication.attempt.succeeded |
account, auth_method, session_type
|
After authentication succeeds | |
authentication.attempt.failed |
account_lookup, auth_method, error_code, error_message
|
After authentication fails | |
authentication.password.failed |
account_lookup, error_code, error_message
|
After password verification fails | |
authentication.otp.failed |
identifier, channel, error_code, error_message
|
After OTP verification fails | |
authentication.method.denied |
account, auth_method, provider, flow, error_message
|
config.login_method_policy refused the method, after the credential was proven and before any session or token was created (0.45+) |
|
| Session | session.creating |
account, session_type, ip_address, user_agent
|
Before a session record is created |
session.created |
session, account, session_type, token_issued, ip_address, user_agent
|
After session persistence completes | |
session.validating |
session |
Before validating an existing session | |
session.validated |
session, account
|
After a session passes validation | |
session.expired |
session, account, expired_at
|
When validation fails because the session expired | |
session.revoked |
session, account, reason
|
After a session is explicitly revoked | |
session.refreshed |
session, account, old_expires_at, new_expires_at
|
After a refresh operation extends a session | |
| Account | account.creating |
account_params, auth_method
|
Before an account record is created |
account.created |
account, auth_method, source (signup/passwordless/social) |
After an account record is created | |
account.verified |
account, verified_via (email/phone) |
When an account is marked verified | |
account.status_changed |
account, old_status, new_status, changed_by
|
When account status transitions (Issue #16) | |
account.locked |
account, lock_reason, locked_by
|
When an account is administratively locked (Issue #17) | |
account.unlocked |
account, unlocked_by
|
When an account lock is lifted (Issue #17) | |
| Identifier | identifier.created |
identifier, account
|
After an identifier record is created |
identifier.verification.started |
identifier, channel (email/sms), code_sent
|
After a verification code is issued | |
identifier.verification.succeeded |
identifier, account, verified_at
|
After identifier verification succeeds | |
identifier.verification.failed |
identifier, error_code, attempts
|
After identifier verification fails | |
identifier.linked |
identifier, account, source (social/manual) |
When an identifier is associated to an account | |
| OAuth | oauth.authorization.requested |
client_id, account, scope, redirect_uri
|
Before issuing an authorization code |
oauth.authorization.granted |
authorization_code, client_id, account, scope
|
After an authorization code is created | |
oauth.authorization.denied |
client_id, account, reason
|
When a user denies authorization | |
oauth.token.issuing |
grant_type, client_id, account, scope
|
Before generating access/refresh tokens | |
oauth.token.issued |
access_token_id, grant_type, client_id, account, expires_in
|
After tokens are generated. For a TokenMinter mint, requested_scopes may be caller-supplied (requested_scopes:) rather than the token's scope
|
|
oauth.token.refreshed |
old_token_id, new_token_id, client_id, account
|
After a refresh token is redeemed | |
oauth.code.consumed |
authorization_code, client_id, account
|
After an authorization code is exchanged | |
oauth.client.registered |
client_application, client_id, client_name, client_type, redirect_uris, scope, owner_type, owner_id, client_metadata
|
After a dynamic registration (RFC 7591) commits. Never fails the registration: a raising subscriber is logged | |
oauth.client.updated |
same as registered
|
After an RFC 7592 update commits | |
oauth.client.deregistered |
same, without client_metadata
|
After an RFC 7592 deregistration commits | |
| Passwordless | passwordless.code.requested |
identifier, channel (email/sms), realm
|
After the challenge row is committed, before code.generated / delivery. Subscribers should not raise: the previous code is already invalidated by then |
passwordless.code.generated |
code_challenge, identifier, channel, expires_at
|
After an OTP is created | |
passwordless.code.sent |
identifier, channel, realm, delivery_status
|
After an OTP is handed to delivery (not for Otp.issue(delivery: :manual)). delivery_status: "sent" (built-in mailer enqueued it), "failed" (built-in mailer was responsible but did not enqueue), "delegated" (a host PASSWORDLESS_CODE_GENERATED subscriber delivers โ the engine can't confirm it) |
|
passwordless.code.verified |
code_challenge, account, channel
|
After OTP verification succeeds | |
passwordless.code.failed |
identifier, channel, attempts
|
After OTP verification fails | |
passwordless.account.created |
account, channel, identifier
|
When an account is created via passwordless flow | |
| Social | social.auth.started |
provider, redirect_uri, state
|
Before redirecting to a social provider |
social.auth.callback_received |
provider, code, state
|
After the provider redirects back | |
social.user_info.fetched |
provider, social_info, email
|
After fetching user info from the provider | |
social.account.created |
account, provider, social_info
|
When a social login creates a new account | |
social.account.linked |
account, provider, identifier
|
When a social login links to an existing account by email (published once the link is written, never for a refused login), or matches an existing link by sub
|
|
social.auth.completed |
account, provider, tokens
|
After social login completes | |
social.auth.failed |
provider, error, error_class, account
|
When social login fails due to an infrastructure error (HTTP/DNS/SSL/timeout) | |
| Credential | credential.password.created |
credential, account
|
After a password credential is created |
credential.password.reset_initiated |
credential, account, reset_token_expires_at
|
After a password reset is initiated | |
credential.password.reset_completed |
credential, account
|
After a password reset is confirmed | |
credential.password.changed |
credential, account, changed_by
|
After a password is updated | |
credential.client_secret.created |
credential, client_id
|
After a client secret is created | |
credential.client_secret.rotated |
credential, client_id, old_secret_revoked_at
|
After a client secret rotation |
Subscribing to Events
Block-based (simple)
# config/initializers/standard_id_events.rb
StandardId::Events.subscribe(StandardId::Events::AUTHENTICATION_SUCCEEDED) do |event|
Analytics.track_login(
account_id: event[:account].id,
method: event[:auth_method],
ip: event[:ip_address]
)
end
# Subscribe to multiple events at once
StandardId::Events.subscribe(
StandardId::Events::SESSION_CREATING,
StandardId::Events::SESSION_VALIDATING,
StandardId::Events::OAUTH_TOKEN_ISSUING
) do |event|
# Handle all three events with the same block
check_rate_limit(event[:account], event[:ip_address])
end
# Subscribe to events with pattern matching
StandardId::Events.subscribe(/social/) do |event|
Rails.logger.info("Social event: #{event.name}")
endAudit Logging
For production audit trails, use the standard_audit gem. StandardId and StandardAudit have zero direct references to each other โ the host application wires them together.
Setup
Add both gems to your Gemfile:
gem "standard_id"
gem "standard_audit"Run the StandardAudit install generator:
rails generate standard_audit:install
rails db:migrateWiring StandardId events to StandardAudit
Configure StandardAudit to subscribe to StandardId's event namespace and map its payload conventions:
# config/initializers/standard_audit.rb
StandardAudit.configure do |config|
config.subscribe_to /\Astandard_id\./
# StandardId uses :account and :current_account rather than :actor/:target.
# Map them so StandardAudit extracts the right records.
config.actor_extractor = ->(payload) {
payload[:current_account] || payload[:account]
}
config.target_extractor = ->(payload) {
# Only set a target when the actor (current_account) differs from the
# account being acted upon โ e.g. an admin locking another user.
if payload[:current_account]
target = payload[:account] || payload[:client_application]
target unless target == payload[:current_account]
end
}
endThat's it. Every StandardId authentication event will now be persisted as an audit log entry. No changes are needed inside StandardId itself.
Querying audit logs
# All auth events for a user
StandardAudit::AuditLog.for_actor(user).reverse_chronological
# Failed logins this week
StandardAudit::AuditLog
.by_event_type("standard_id.authentication.attempt.failed")
.this_week
# All activity from an IP address
StandardAudit::AuditLog.from_ip("192.168.1.1")See the StandardAudit README for the full query interface, async processing, GDPR compliance, and multi-tenancy support.
Instrumentation (tracing spans)
Separately from the domain events above, the OAuth token endpoint is instrumented with ActiveSupport::Notifications block events so you can add tracing spans or timings without patching gem internals. Names follow the Rails <event>.<library> convention, so standard_id.* audit subscribers never see them:
Event (StandardId::Instrumentation::โฆ) |
Wraps | Payload |
|---|---|---|
AUTHENTICATE โ authenticate.standard_id
|
every token grant's authenticate! (for refresh_token: JWT decode + token lookup + reuse detection) |
flow, grant_type
|
AUDIENCE_PROFILE_BINDING โ audience_profile_binding.standard_id
|
audienceโprofile binding (account load + resolver) |
flow, grant_type, audience
|
AUDIENCE_PROFILE_RESOLVE โ audience_profile_resolve.standard_id
|
Oauth::AudienceProfileResolver.resolve! (nested inside the binding event) |
audience |
A raised error appears as :exception / :exception_object in the finish payload. Sentry child spans, nested the same way:
# config/initializers/standard_id_tracing.rb
return unless defined?(Sentry)
module StandardIdSentrySpans
STACK = :standard_id_sentry_spans
def self.start(name, _id, payload)
scope = Sentry.get_current_scope
parent = scope&.get_span
# No transaction in progress (a job, a console) โ no parent โ no span.
span = parent&.start_child(
op: "standard_id.#{name.delete_suffix('.standard_id')}",
description: Array(payload[:audience]).join(", ").presence
)
(Thread.current[STACK] ||= []) << [span, parent]
scope.set_span(span) if span
end
def self.finish(_name, _id, _payload)
span, parent = Thread.current[STACK]&.pop
return unless span
span.finish
# Restore the parent. Guarded: Scope#set_span raises ArgumentError on nil,
# and the current scope may have changed since #start.
scope = Sentry.get_current_scope
scope.set_span(parent) if scope && parent
end
end
ActiveSupport::Notifications.subscribe(StandardId::Instrumentation::PATTERN, StandardIdSentrySpans)Account Status (Activation/Deactivation)
StandardId provides an optional AccountStatus concern for managing account activation and deactivation. This uses Rails enum with the event system to enforce status checks and handle side effects without modifying core authentication logic.
Setup
- Add a migration for the status column. For PostgreSQL (recommended), use a native enum type:
# PostgreSQL with native enum (recommended)
class AddStatusToUsers < ActiveRecord::Migration[8.0]
def up
create_enum :account_status, %w[active inactive]
add_column :users, :status, :enum, enum_type: :account_status, default: "active", null: false
add_column :users, :activated_at, :datetime
add_column :users, :deactivated_at, :datetime
end
def down
remove_column :users, :status
remove_column :users, :activated_at
remove_column :users, :deactivated_at
drop_enum :account_status
end
endFor other databases (MySQL, SQLite), use a string column:
# String column (MySQL, SQLite)
class AddStatusToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :status, :string, default: "active", null: false
add_column :users, :activated_at, :datetime
add_column :users, :deactivated_at, :datetime
add_index :users, :status
end
end- Include the concern in your account model:
class User < ApplicationRecord
include StandardId::AccountStatus
# ...
endThe concern works with both PostgreSQL enum and string columns - Rails enum handles both transparently.
If your column already carries a different value for a deactivated account,
keep it: c.inactive_account_status = :suspended (set in the initializer,
before the model loads) makes the concern declare { active: "active", suspended: "suspended" }, so suspended?, the suspended scope and
deactivate! all use your word, and the sign-in / token / live-session guard
raises AccountDeactivatedError, "Account is suspended". inactive? remains as
an alias. activated_at / deactivated_at are stamped only when the columns
exist.
Usage
# Deactivate an account
user.deactivate!
# => Emits ACCOUNT_DEACTIVATED event
# => All active sessions are automatically revoked
# Reactivate an account
user.activate!
# => Emits ACCOUNT_ACTIVATED event
# => User can log in again
# Check status
user.active? # => true/false
user.inactive? # => true/false
# Query scopes
User.active # => Users with status 'active'
User.inactive # => Users with status 'inactive'Handling AccountDeactivatedError
When an inactive account attempts to authenticate โ at sign-in, at token mint, and (since 0.36.0) on every request made with an already-issued credential โ StandardId::AccountDeactivatedError is raised. You need to handle this error in your application controller:
What the engine's own routes do. You are responsible for your controllers only. The engine handles its own:
ApiEngineresource routes (/api/v1/sessions,/api/v1/userinfo, โฆ) answer401witherror: "invalid_token"plus aWWW-Authenticatechallenge, per RFC 6750 ยง3.1 โ the caller presented an access token and must be told to discard it.StandardId::Api::BaseControllerdescends fromActionController::API, so yourrescue_fromis not in its ancestry and could never have covered it.ApiEngineOAuth protocol routes (POST /api/oauth/token,/introspect,/revoke,/register) answer400witherror: "invalid_grant"per RFC 6749 ยง5.2 โ a client at the token endpoint is obtaining a credential, not presenting one, so there is no bearer token to challenge. Theerror_descriptionis deliberately generic ("The provided authorization grant is invalid, expired or revoked") and identical for deactivated and locked accounts: whoever presents a grant may not be its legitimate holder, so the response must not confirm account state.lock_reasonis never surfaced.WebEngineroutes (/sessions,/account,/logout) are covered by therescue_fromyou write onApplicationControllerbelow, becauseStandardId::Web::BaseControllerdescends from it. The engine deliberately registers no handler of its own there โ one would take precedence over yours and override your UX.So: write the
ApplicationControllerhandler (it covers your pages and the web engine's), and write theApi::BaseControllerhandler only for your own API controllers.
โ ๏ธ Never guard a
rescue_fromwith arescue_handlerscheck. A shim written like this looks defensive and is a trap:# DON'T โ silently becomes a no-op the moment the gem registers its own handler unless StandardId::Api::BaseController.rescue_handlers.any? { |k, _| k == "StandardId::AccountDeactivatedError" } StandardId::Api::BaseController.rescue_from(StandardId::AccountDeactivatedError) { ... } endWhen a superclass in the gem gains a handler for that class โ as
Api::BaseControllerdid in 0.36.1 โ the guard stops matching, your block never registers, and the gem's response shape silently replaces yours with no error, no deprecation, and nothing in the diff. Register unconditionally instead: Rails resolvesrescue_fromhandlers most-recently-registered-first, so a later registration already wins over the gem's without any guard. This is exactly how one consumer's400 invalid_granttoken-endpoint shim was defeated by the gem's401 invalid_token.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include StandardId::WebAuthentication
rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
private
def handle_account_deactivated
# For web requests, redirect with a message
redirect_to login_path, alert: "Your account has been deactivated. Please contact support."
end
endFor API controllers:
# app/controllers/api/base_controller.rb
class Api::BaseController < ActionController::API
include StandardId::ApiAuthentication
rescue_from StandardId::AccountDeactivatedError, with: :handle_account_deactivated
private
def handle_account_deactivated
render json: {
error: "account_deactivated",
message: "Your account has been deactivated"
}, status: :forbidden
end
endAccount Locking (Administrative Security)
StandardId provides an optional AccountLocking concern for administrative account locking. This is distinct from account deactivation - locking is for security enforcement by administrators, while deactivation is for lifecycle management.
Key Differences from Account Deactivation
| Feature | Account Status | Account Locking |
|---|---|---|
| Purpose | Lifecycle management | Security enforcement |
| Who Controls | System/User | Admin/Staff only |
| User Reversible | Yes (future) | No |
| Use Cases | Inactivity, user choice | Policy violation, security incident, fraud |
An account can be in any combination:
- Active + Unlocked โ (normal operation)
- Active + Locked โ ๏ธ (admin locked for security)
- Inactive + Unlocked โ ๏ธ (deactivated but not locked)
- Inactive + Locked ๐ซ (both restrictions apply)
Setup
- Add a migration for the locking columns:
class AddLockingToUsers < ActiveRecord::Migration[8.0]
def change
add_column :users, :locked, :boolean, default: false, null: false
add_column :users, :locked_at, :datetime
add_column :users, :lock_reason, :string
add_column :users, :locked_by_id, :integer
add_column :users, :locked_by_type, :string
add_column :users, :unlocked_at, :datetime
add_column :users, :unlocked_by_id, :integer
add_column :users, :unlocked_by_type, :string
add_index :users, :locked
add_index :users, [:locked_by_type, :locked_by_id]
end
end- Include the concern in your account model:
class User < ApplicationRecord
include StandardId::AccountLocking # For admin locking
include StandardId::AccountStatus # Optional: for activation/deactivation
# ...
endUsage
# Lock an account (revokes all active sessions immediately)
user.lock!(reason: "Suspicious activity detected", locked_by: current_admin)
# => Emits ACCOUNT_LOCKED event
# => All active sessions (browser, device, service) are revoked
# Unlock an account (user must log in again)
user.unlock!(unlocked_by: current_admin)
# => Emits ACCOUNT_UNLOCKED event
# => User can log in again
# Check lock status
user.locked? # => true/false
user.unlocked? # => true/false
# Query scopes
User.locked # => Users with locked = true
User.unlocked # => Users with locked = false
# Combine with AccountStatus scopes
User.unlocked.active # => Users who can log inHandling AccountLockedError
When a locked account attempts to authenticate, StandardId::AccountLockedError is raised. The error includes metadata about the lock. The same engine-owned-route rules described under Handling AccountDeactivatedError apply: ApiEngine resource routes answer 401 invalid_token themselves, ApiEngine OAuth protocol routes answer 400 invalid_grant, and WebEngine routes defer to your ApplicationController handler. Neither engine-owned response ever echoes lock_reason.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
include StandardId::WebAuthentication
rescue_from StandardId::AccountLockedError, with: :handle_account_locked
private
def handle_account_locked(error)
# error.lock_reason - Why the account was locked (avoid exposing to end users)
# error.locked_at - When the account was locked
redirect_to login_path, alert: "Your account has been locked. Please contact support."
end
endFor API controllers:
# app/controllers/api/base_controller.rb
class Api::BaseController < ActionController::API
include StandardId::ApiAuthentication
rescue_from StandardId::AccountLockedError, with: :handle_account_locked
private
def handle_account_locked(error)
render json: {
error: "account_locked",
message: "Your account has been locked. Please contact support.",
locked_at: error.locked_at&.iso8601
# Note: Consider not exposing lock_reason to end users for security
}, status: :forbidden
end
endEvent Subscriptions
Both AccountStatus and AccountLocking subscribe to the same events (OAUTH_TOKEN_ISSUING, SESSION_CREATING, SESSION_VALIDATING). The lock check runs alongside the status check - authentication fails if either condition prevents access.
Usage Examples
Web Authentication
<!-- Login form -->
<%= form_with url: login_path, local: true do |f| %>
<%= f.email_field :email, placeholder: "Email" %>
<%= f.password_field :password, placeholder: "Password" %>
<%= f.check_box :remember_me %>
<%= f.label :remember_me, "Remember me" %>
<%= f.submit "Sign In" %>
<% end %>OAuth Authorization
# Redirect to authorization endpoint
redirect_to "/api/authorize?" + {
response_type: "code",
client_id: "your_client_id",
redirect_uri: "https://your-app.com/callback",
scope: "openid profile email",
state: "random_state_value"
}.to_queryWhich audience is the token for? /authorize resolves it server-side
(StandardId::Oauth::AudienceResolver), in this order: config.oauth.audience_resolver
when set; the client's registered default metadata["audience"] (server-set, so it
outranks the request); the audience parameter; the RFC 8707 resource parameter
mapped through config.oauth.resource_audiences. The result is bound to the authorization code and reaches
the token as aud, and config.oauth.audience_scopes is enforced for it at
/authorize (a scope outside the audience's vocabulary is invalid_scope). So a
dynamically registered MCP client whose registration stamps
metadata: { "audience" => "mcp" } gets aud=mcp with no audience-injecting shim
in front of the endpoint. The resource parameter is ignored until
resource_audiences is set; once it is, an unmapped resource is invalid_target
(RFC 8707 ยง2).
For a client with no registered default, a client-supplied audience is taken as
is, so audience_scopes enforcement for such clients is only as strong as
config.oauth.allowed_audiences at the token endpoint.
Which scope? config.oauth.authorize_scope_resolver normalises the requested
scope once the client and audience are known and before audience_scopes is
enforced โ collapse a hierarchy, narrow to what the audience consumes, append a
default โ and its answer is what the code is issued with and the consent screen
shows:
config.oauth.authorize_scope_resolver = ->(scope:, audience:) {
tokens = scope.to_s.split
next nil unless audience == "mcp" && tokens.include?("mcp") # nil leaves the request as is
tokens - ["mcp:read"] # "mcp" already implies read; an Array or a String is fine
}It runs again when a consent resumes, so make it idempotent (its own output must come back unchanged); it also decides what the consent screen shows and what the grant records.
StandardId.configure do |config|
config.oauth.resource_audiences = { "https://app.example.com/mcp" => "mcp" } # RFC 8707
# or decide yourself:
config.oauth.audience_resolver = ->(client:, resource:) { client.metadata["audience"] || (resource && "mcp") }
endSocial Login
# Google login
redirect_to "/api/authorize?" + {
response_type: "code",
client_id: "your_client_id",
redirect_uri: "https://your-app.com/callback",
connection: "google"
}.to_query
# Apple login
redirect_to "/api/authorize?" + {
response_type: "code",
client_id: "your_client_id",
redirect_uri: "https://your-app.com/callback",
connection: "apple"
}.to_queryHow a social login finds its account (0.44+)
-
Provider + subject. A login whose
(provider, sub)is stored instandard_id_social_identitiessigns in to that account. The email the provider reports is not consulted. -
Existing email identifier. Otherwise, if the email belongs to an existing account, the login links to it only when all of these hold, and raises
StandardId::SocialLinkError(after emittingSOCIAL_LINK_BLOCKED) when one does not. The error'sreasonsays which:-
link_strategyallows it (:link_requiredunder:strict). Under:strict, a provider whosetrusted_for_linking?returnstruemay link to an account created through another provider, but only when the existing email identifier is itself verified (see below); - the identifier is not already linked to a different
subfrom the same provider (:subject_mismatch); - the provider reports the email as verified:
email_verifiedistrueor the string"true"(:email_unverified). This applies under:trust_providertoo.
A successful link stores the
sub, so the next login matches on step 1. If a concurrent login commits the samesubfor a different account while this one writes its link, the login fails with a retryableinvalid_grant(StandardId::SocialLinkConflictError) andSOCIAL_LINK_BLOCKEDis published with reason:subject_conflict. -
-
New account. Otherwise a new account is created, as before, and the
subis stored.
Providers must return the OIDC sub and email_verified claims in
user_info. A provider that returns no sub still works, but is matched on
email (with the verified-email requirement) every time.
Trusted linking for the organisation's own IdP (0.45+)
:strict refuses to link, say, an org-SSO login to an existing account that
was first created by a Google login, even when the addresses match. A provider
plugin can lift that one refusal by returning true from
Providers::Base.trusted_for_linking? (default false for every provider,
including Google and Apple, so nothing changes unless a plugin opts in).
Only the organisation's own identity provider should ever return true:
one whose email claims the organisation itself provisions and verifies, such as
a broker-local directory (moneta, via standard_id-void_which_binds). A public
IdP lets anyone register an address there; trusting it would let whoever holds
an address at that IdP take over the account holding the same address here.
Trust relaxes only the :strict cross-provider check. The link is still
refused unless all of these hold:
- the provider reports
email_verifiedastrue/"true"(:email_unverifiedotherwise); - the existing email identifier is verified (
verified_atset). This closes pre-account hijacking: someone who registered an account for an address they never proved must not have it handed to the address's real owner (they would keep their own way in). Refused as:link_required; - the identifier is not linked to a different
subfrom the same provider (:subject_mismatch); -
trusted_for_linking?returns exactlytrue(a truthy string does not count).
A stored (provider, sub) match still wins over any email match.
Restricting sign-in methods (login_method_policy, 0.45+)
c.login_method_policy decides, per account, which authentication methods may
sign it in, for example "staff must sign in with the org IdP". The default
(nil) allows everything.
StandardId.configure do |c|
# Any subset of: account:, auth_method:, provider:, request:, flow:
c.login_method_policy = ->(account:, auth_method:, provider:) {
next true unless account.staff?
next true if auth_method == :social && provider == "void_which_binds"
raise StandardId::LoginMethodDenied, "Staff must sign in with Void Which Binds"
}
endReturn a truthy value to allow, false/nil to refuse with a generic
message, or raise StandardId::LoginMethodDenied with your own message. Any
other exception propagates, so a broken policy fails closed.
The policy is consulted in every flow that establishes a new authentication, after the credential is proven (password checked, code verified, provider token verified) and before any session, token or cookie is created. Because it runs only after the credential is proven, its message reaches only someone who already controls the credential; a wrong password still gets the flow's usual "invalid" answer and never reaches the policy.
flow |
auth_method |
Refusal |
|---|---|---|
:web_password (/login) |
:password |
redirect to /login, message as flash alert |
:web_signup (/signup) |
:password |
same; the just-created account is removed |
:web_passwordless (/login_verify) |
:passwordless |
same; a just-registered account is removed |
:web_social (/auth/callback/:provider) |
:social + provider
|
same; a just-created account is removed |
:web_remember_me (remember-me cookie) |
:remember_me |
signed out silently, remember cookie cleared |
:web_session (session_manager.sign_in_account from host code) |
what you pass, else :unspecified
|
raises LoginMethodDenied (an AuthenticationDenied) |
:oauth_password_grant |
:password |
403 {"error":"access_denied"} |
:oauth_passwordless_otp_grant |
:passwordless |
403 access_denied; a just-registered account is removed |
:oauth_social_callback (/api/oauth/callback/:provider) |
:social + provider
|
403 access_denied; a just-created account is removed |
:oauth_refresh_token |
the original sign-in's method (and provider), else :unspecified
|
400 invalid_grant ("Refresh token is no longer valid"), and the token family is revoked |
:api_device_session / :api_service_session (Api::TokenManager#create_*_session from host code) |
what you pass, else :unspecified
|
raises LoginMethodDenied
|
:api_email_password (/api/auth/login, /api/auth/verify-email) |
:password |
403 {"error":"login_method_denied"} (through error_renderer when set) |
Refresh tokens remember how their authentication was made. The method
(and provider) is recorded at sign-in and carried along: web sign-in โ the
browser session's metadata โ the authorization code minted from it at
/api/authorize โ the refresh token (auth_method / auth_provider columns,
migration 20261004000000) โ every rotated successor. The refresh_token
grant re-checks the policy with that original method, so tightening the policy
takes effect at each OAuth client's next refresh. A refused refresh is answered
like any dead refresh token (invalid_grant, nothing about why, since whoever
presents a refresh token may not be its holder) and revokes the family; the
reason is in the authentication.method.denied event. Refresh tokens minted
before 0.45 (or before the migration ran) have no recorded method and are
checked as :unspecified: a policy that requires a particular method will
refuse them, failing closed, and those clients must sign in again.
Not gated: the authorization_code grant and the implicit flow (minted within
minutes from a browser session that passed the policy when it was created) and
client_credentials (no account). Browser sessions that already exist are not
re-checked when the policy changes; revoke them if they must end.
A refused social login also leaves no trace of the link: the (provider, sub)
row and any provider backfilled onto the email identifier are written only once
the login is accepted (on the web, together with the final redirect), so a
refusal by the policy, before_sign_in, a scope check or a failing redirect
writes nothing and publishes no social.account.linked.
A just-created account is removed only if no other sign-in is using it. When a
concurrent login for the same email has already signed in to it, it is kept;
when the removal goes first, that login fails with a retryable error (web:
redirect to /login; API: 400 invalid_grant), never signs in to the removed
account, and a retry creates a fresh one.
Every refusal publishes authentication.method.denied.
Host code that signs accounts in itself (session_manager.sign_in_account,
Api::TokenManager#create_device_session) should pass the method, e.g.
session_manager.sign_in_account(account, auth_method: :password), and rescue
StandardId::AuthenticationDenied (which LoginMethodDenied subclasses) as the
engine's controllers do. Without auth_method: the policy sees
:unspecified, which a restrictive policy will refuse.
Passwordless Authentication
# Start passwordless flow
POST /api/passwordless/start
{
"connection": "email",
"username": "user@example.com"
}
# Verify code
POST /api/passwordless/verify
{
"connection": "email",
"username": "user@example.com",
"otp": "123456"
}Email + password API (0.55+)
Opt-in sign-up and sign-in with an email and a password for JSON clients
(mobile apps, SPAs), alongside the OAuth grants. Off by default; the routes are
not drawn until you enable it. It needs migration
20261022000000_add_signup_source_to_standard_id_identifiers, so run
bin/rails standard_id:install:migrations && bin/rails db:migrate before
enabling it.
StandardId.configure do |c|
c.email_password.enabled = true
c.email_password.reset_url = "https://app.example.com/password-reset?token={token}"
# Optional hooks (each may declare any subset of its keywords):
c.email_password.require_name = true
c.email_password.client_key = ->(request:) { MaskedNetwork.for(request) } # default: remote IP
c.email_password.password_validator = ->(password:) { PwnedPasswords.check(password) } # => Array of messages
c.email_password.after_email_verified = ->(account:, name:) { Profile.provision!(account, name) }
c.email_password.account_in_use = ->(account:) { Profile.exists?(account: account) }
end| Request (under the API mount) | Body | Success |
|---|---|---|
POST /auth/register |
email, password, name?
|
202 {"status":"verification_required","email":โฆ} |
POST /auth/verify-email |
email, code, password
|
200 token response |
POST /auth/verify-email/resend |
email, password
|
202 {"status":"verification_sent"}, always |
POST /auth/login |
email, password
|
200 token response |
POST /auth/password/forgot |
email |
202 {"status":"reset_requested"}, always |
POST /auth/password/reset |
token, password, password_confirmation?
|
200 {"status":"password_reset"} |
Requests must be JSON. Token responses are the usual TokenResponse and
refresh through POST /oauth/token with client_id =
c.email_password.token_client_id (default "email").
The rules:
-
Sign-up creates an unverified account and emails a 6-digit code (
PasswordlessMailer#verification_email, or your ownPASSWORDLESS_CODE_GENERATEDsubscriber withcode_delivery = :custom). No tokens until the address is verified. -
Verifying needs the code and the sign-up password, so a stranger who started a sign-up for someone else's address cannot finish it with the owner's code. A wrong password answers exactly like a wrong code. Outside production,
passwordless.bypass_codeis accepted as the code (as everywhereOtp.verifyruns);after_email_verifiedthen receives noname. -
Re-submitting sign-up for a pending address never changes its password. With the same password it sends a fresh code; with a different one it answers the same
202and sends nothing. -
Login answers
unverified(403) only when the password is right; everything else (unknown address, wrong password, deactivated or locked account) is oneinvalid_credentials(401). -
Resend and forgot-password answer the same whatever the address. The reset lookup happens in
PasswordResetDeliveryJob. -
A reset link sets the password, verifies the address (the link proved the inbox; a pending sign-up's
after_email_verifiedruns then) and revokes every session and refresh token. It is single-use and expires withPasswordCredential'spassword_resettoken. -
Existing accounts: sign-up for an address with a verified password account answers
409 account_exists, and for a social-only account409 social_account. Setc.email_password.reveal_existing_accounts = falseto answer those like a new address instead and email the owner (AccountNoticeMailer#existing_account). -
login_method_policyis consulted withflow: :api_email_password. -
Hooks and events.
ACCOUNT_CREATEDandCREDENTIAL_PASSWORD_CREATEDfire at sign-up, for an address that is not verified yet; provision anything that should wait for a proven address fromafter_email_verified. That hook runs once, when the address is first verified (by code or reset link), in the verification's transaction: if it raises, the address stays unverified and the registrant retries with a new code or link. It runs only for a sign-up made through this API (its identifier'ssignup_sourceis"email_password"): a web sign-up verified here, by code or reset link, is verified without it. It runs with the address's rows locked, so keep slow work (an external call) in a job it enqueues.IDENTIFIER_VERIFICATION_SUCCEEDEDis published only after that transaction commits. A failing subscriber to it is logged, not raised, since the verification has already committed. Tokens are minted under the password credential's row lock, so a reset racing a login or a verification either lands first (that request then fails) or revokes what it minted.
Errors are StandardId::EmailPasswordError codes, rendered as
{"error": code, "error_description": message, "details"?: {field: [messages]}}:
| Code | Status |
|---|---|
invalid_credentials |
401 |
unverified, login_method_denied
|
403 |
account_exists, social_account
|
409 |
invalid_code, invalid_reset_token, weak_password, invalid_request
|
422 |
rate_limited (with Retry-After) |
429 |
To keep an existing envelope, return your own body from
c.email_password.error_renderer = ->(error:, request:) { โฆ }; the status stays
error.http_status.
Limits. Each endpoint has short per-client-key and per-address limits that
reuse c.rate_limits (signup_per_ip, login_per_*, verification_start_*,
password_reset_start_*, otp_verify_per_ip) plus
email_password_verify_per_email and email_password_reset_per_ip; like the
engine's other rate_limit declarations they are read when the controller
loads, so set them in an initializer. Daily per-address caps sit on top:
email_password_failed_verifications_per_day (then verify is refused for the
day, even with the right code), email_password_codes_per_day,
email_password_reset_requests_per_network_per_day and
email_password_reset_requests_per_day. All of them count in
c.cache_store, so a NullStore disables them. Only a wrong code sent with the
right password counts toward the daily verify lockout, but the short
email_password_verify_per_email limit counts every request, so anyone can
hold an address's verification off for that window.
Housekeeping. Schedule StandardId::PurgeUnverifiedEmailSignupsJob (see
docs/OPERATIONS.md). If you link social sign-ins by
email, call StandardId::EmailPassword.discard_pending_credentials!(account, issued_before:) from a social.account.linked subscriber, so an owner who
signs in with a provider does not inherit a stranger's pending password.
The operations behind the controller (EmailPassword::Registration,
Authentication, Verification, PasswordReset, TokenFlow) are public, so
a host can drive them from its own controllers instead.
API Authentication
# In your API controllers
class Api::UsersController < ApiController
before_action :authenticate_account!
def show
render json: current_account
end
endPrimitives
StandardId also ships a couple of thin, config-free JWT primitives for use cases that have nothing to do with OAuth sessions โ for example, service-to-service tokens between two of your own backends.
JwtService.sign / JwtService.verify
These are low-level wrappers around JWT.encode / JWT.decode. They do not
read StandardId.config โ you supply the algorithm and key directly, and you
control the full payload. No iss, aud, or iat is added for you.
# Sign an HS256 service token
token = StandardId::JwtService.sign(
{ sub: "harness", aud: "sidekick", gid: "gid://..." },
algorithm: "HS256",
key: Rails.application.credentials.dig(:service_jwt, :secret),
expires_in: 5.minutes
)
# Verify it on the receiving side
payload = StandardId::JwtService.verify(
token,
algorithm: "HS256",
key: Rails.application.credentials.dig(:service_jwt, :secret),
allowed_audiences: %w[sidekick]
)
payload["sub"] # => "harness"Supports:
- HS256 / HS384 / HS512, RS256 / RS384 / RS512, ES256 / ES384 / ES512
-
expires_in:to auto-setexp(caller-suppliedexpwins) - Arbitrary JWT headers via
**extra_headers(e.g.kid:) -
allowed_audiences:to enforce theaudclaim -
key:as a single value or anArrayโ keys are tried in order, so rotation is a one-liner - Failure raises
StandardId::InvalidTokenError(with subclassesExpiredTokenError,InvalidSignatureError,InvalidAlgorithmError,InvalidAudienceTokenError) โ nonilreturns
When to use which
| Use case | API |
|---|---|
| OAuth 2.0 / OIDC access and ID tokens, browser/device sessions |
JwtService.encode / .decode / .decode_session
|
| Anything else (service-to-service, internal signed payloads, webhooks) |
JwtService.sign / .verify
|
The OAuth/session methods consult StandardId.config (issuer, signing key,
rotation, claim resolvers, etc.) and add standard claims automatically. The
primitives deliberately do none of that.
Database Schema
StandardId creates the following tables:
-
standard_id_accounts- User accounts -
standard_id_identifiers- Email/phone identifiers (STI) -
standard_id_sessions- Authentication sessions (STI) -
standard_id_clients- OAuth clients -
standard_id_client_secret_credentials- Client secrets -
standard_id_password_credentials- Password storage -
standard_id_code_challenges- OTP codes for authentication and verification
API Endpoints
Web Routes (mounted at /)
-
GET /login- Login form -
POST /login- Process login -
POST /logout- Logout -
GET /signup- Signup form -
POST /signup- Process signup -
GET /account- Account management -
GET /sessions- Active sessions
API Routes (mounted at /api)
-
GET /authorize- OAuth authorization endpoint -
POST /oauth/token- Token exchange endpoint -
GET /userinfo- OpenID Connect userinfo -
POST /passwordless/start- Start passwordless flow -
POST /passwordless/verify- Verify OTP code -
POST /auth/register,/auth/login,/auth/verify-email,/auth/verify-email/resend,/auth/password/forgot,/auth/password/reset- Email + password, whenc.email_password.enabled -
GET /oauth/callback/google- Google OAuth callback -
POST /oauth/callback/apple- Apple Sign In callback
Client Management
# Create OAuth client
client = StandardId::ClientApplication.create!(
owner: current_account,
name: "My Application",
redirect_uris: "https://app.com/callback",
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
scopes: ["openid", "profile", "email"]
)
# Generate client secret
secret = client.create_client_secret!(name: "Production Secret")
# Rotate client secret
new_secret = client.rotate_client_secret!Writing a Provider Plugin
Social providers ship as separate gems (standard_id-google,
standard_id-apple) that subclass StandardId::Providers::Base and register
themselves. A minimal plugin is a provider class plus a two-line entry file.
# lib/standard_id/github.rb โ the gem's entry file
require "standard_id"
require "standard_id/github/providers/github"
# Defines the Railtie that registers the provider after the host app has
# initialized. No hand-written railtie.rb, no `if defined?(Rails)` guard.
StandardId::Providers.plugin_railtie(:github, "StandardId::Providers::GitHub")# lib/standard_id/github/providers/github.rb
module StandardId
module Providers
class GitHub < Base
AUTH_ENDPOINT = "https://github.com/login/oauth/authorize".freeze
TOKEN_ENDPOINT = "https://github.com/login/oauth/access_token".freeze
class << self
def provider_name = "github"
def default_scope = "read:user user:email"
def supported_authorization_params = %i[scope login allow_signup]
# Fields land in the `social` config scope. `env:` and `required:` are
# read by StandardId (>= 0.42) and not passed to ConfigSchema.
def config_schema
{
github_client_id: { type: :string, default: nil }, # ENV GITHUB_CLIENT_ID
github_client_secret: { type: :string, default: nil, required: true },
github_enterprise_host: { type: :string, default: nil, env: false } # no ENV fallback
}
end
def authorization_url(state:, redirect_uri:, **options)
build_authorization_url(
endpoint: AUTH_ENDPOINT,
client_id: StandardId.config.github_client_id,
redirect_uri:, state:, options:,
defaults: { scope: default_scope }
)
end
def get_user_info(code: nil, redirect_uri: nil, **)
rescue_to_oauth_error do
raise StandardId::InvalidRequestError, "Missing authorization code" if code.blank?
response = HttpClient.post_form(TOKEN_ENDPOINT, { code:, redirect_uri:, ... })
parsed = JSON.parse(response.body)
build_response(fetch_profile(parsed["access_token"]), tokens: extract_tokens(parsed))
end
end
end
end
end
endRequired interface: provider_name, authorization_url, get_user_info.
Optional hooks (all class methods, with safe defaults):
| Hook | Default | Purpose |
|---|---|---|
config_schema |
{} |
social config fields. Per-field env: (String, or false to opt out; default is the upper-cased field name) and required: true
|
enabling_config_field |
:<provider_name>_client_id if declared |
Field whose presence switches the provider on |
required_config_fields |
fields with required: true
|
Must be present while enabled; reported by configuration_errors and the boot check |
enabled? / configuration_errors / configured?
|
derived from the two above | Override for enablement rules the fields cannot express |
default_scope |
nil |
Scope for the social-login grant |
supported_authorization_params |
[] |
Params build_authorization_url forwards from options. Include :nonce for OIDC |
resolve_params(params, context:) |
params |
Adjust params per flow (context[:flow] is :web or :mobile) |
flow_for(params) |
:web only for flow=web on providers that supports_mobile_callback?, else :mobile
|
Flow for the API callback |
skip_csrf? |
false |
true for POST (form_post) callbacks |
supports_mobile_callback? |
false |
Enables the server-side redirect back to a native app |
trusted_for_linking? |
false |
true lets :strict link to an account created via another provider (verified emails only). Org-owned IdPs only; see "Trusted linking" (0.45+) |
supports_pkce? |
false |
true makes core generate, store and hand back a PKCE (S256) verifier for the web flow; see "Callback iss and core-managed PKCE" (0.46+) |
Protected helpers for use inside those methods โ signatures are stable:
| Helper | Does |
|---|---|
build_response(user_info, tokens:) |
The standard get_user_info return value |
build_authorization_url(endpoint:, client_id:, redirect_uri:, state:, options: {}, defaults: {}, response_type: "code") |
client_id, redirect_uri, response_type, state, then each supported_authorization_params entry from options or defaults, then code_challenge / code_challenge_method from options; nils dropped |
extract_tokens(parsed_token) |
{ access_token:, refresh_token:, id_token: } from a token response, nils dropped |
verify_nonce!(expected:, actual:) |
Constant-time nonce check; no-op when expected is blank. Raises InvalidRequestError without echoing either value |
rescue_to_oauth_error(message_prefix = nil) { ... } |
Lets StandardId::OAuthError through; wraps anything else in one, keeping cause
|
pkce_s256_challenge(code_verifier) (public) |
BASE64URL(SHA256(verifier)), unpadded (RFC 7636 S256) |
A plugin using env:, required: or these helpers should depend on
standard_id >= 0.42. Providers::Base.setup is no longer called (removed
from the base class in 0.42; the call-with-a-warning shim went in 0.43) โ do
one-off initialization in your own Railtie instead.
Callback iss and core-managed PKCE (0.46+)
Core hands two more values to get_user_info, so a provider does not have to
capture or derive them itself. Both arrive through **options; a provider
that does not name them ignores them, and a nil value is never passed.
-
callback_iss:โ the callback'sissparameter (RFC 9207), when the redirect carried one as a String. Compare it with the issuer you expect before exchanging the code (authorization-server mix-up defence). It is passed on both the web callback and/api/oauth/callback/:provider(where it is whatever the client relayed); refuse a missing one if your IdP always sends it. -
code_verifier:โ for a provider whosesupports_pkce?returnstrue. On every/login?connection=<provider>core generates a fresh verifier, stores it with the state and nonce in the encrypted pending-requests cookie (it never appears in a URL), and passescode_challenge:andcode_challenge_method: "S256"toauthorization_url(build_authorization_urlemits both). At the web callback the stored verifier comes back ascode_verifier:; send it in the token request. A web callback whose stored request has no verifier (a state issued for another provider, or one started before the provider opted in) is refused before the provider is called.
def self.supports_pkce? = true
def self.get_user_info(code: nil, redirect_uri: nil, nonce: nil, callback_iss: nil, code_verifier: nil, **_options)
raise StandardId::InvalidRequestError, "issuer mismatch" unless callback_iss == EXPECTED_ISSUER
raise StandardId::InvalidRequestError, "web sign-in only" if code_verifier.blank?
# POST code, redirect_uri and code_verifier to the token endpoint ...
endThe API paths keep no server-side flow state, so they never pass a
code_verifier: (a client-supplied code_verifier param is not forwarded),
and the API social login grant (/authorize?connection=...) refuses to start
a sign-in for a provider that supports_pkce? rather than send it without a
challenge. A PKCE provider is therefore web-only unless it handles a native
flow itself.
Testing a plugin, or an app that uses one:
# spec/rails_helper.rb
require "standard_id/testing"
# spec/initializers/standard_id_providers_spec.rb
RSpec.describe "StandardId social providers" do
it_behaves_like "a registered StandardId provider", :google
it_behaves_like "a registered StandardId provider", :apple
end
expect(:apple).to be_a_registered_standard_id_provider.with_config_fields(:apple_client_id, :apple_team_id)The shared example checks the provider is registered, that its fields
(default: its whole config_schema) are declared on the social scope, and
that each accepts a write the way config/initializers/standard_id.rb makes
one.
Schema DSL
Schema is declared using a routes-like DSL and can be extended by provider gems:
# core gem (already provided)
require "standard_id/config/schema"
StandardConfig.schema.draw do
scope :base do
field :account_class_name, type: :string, default: "User"
end
scope :social do
field :google_client_id, type: :string, default: nil
end
end
# provider gem
require "standard_id/config/schema"
StandardConfig.schema.draw do
scope :social do
field :my_provider_client_id, type: :string, default: nil
end
endNotes:
- Multiple
schema.drawcalls are additive; the same scope can be extended in multiple files/gems. - Redefining an existing field will emit a warning; last definition wins.
Testing
Signing in from your app's request specs
# spec/rails_helper.rb
require "standard_id/testing"
RSpec.configure do |config|
config.include StandardId::Testing::RequestHelpers, type: :request
endsign_in_as(account) # a real BrowserSession, via the session_token cookie
sign_in_as(account, scope: :platform) # also grants standard_id_scopes (current_scope_names)
get "/dashboard"
get "/api/orders", headers: bearer_headers_for(account, scope: "orders.read")sign_in_as creates a real BrowserSession and writes the same encrypted
session_token cookie a real sign-in writes, so current_account,
require_browser_session!, expiry and revocation behave exactly as in
production. It adds nothing to your app: the cookies are built with your app's
own encrypted cookie jar, so there is no test-only route. scope: writes the
Rails session cookie and so needs the cookie session store (the Rails default);
with another store it raises ArgumentError, and you sign in through your own
scoped login request instead. It replaces controller stubbing
(stub_web_authentication, allow_any_instance_of(...).to receive(:current_user))
and per-app /test/set-session routes.
This gem's own suite
StandardId includes comprehensive test coverage:
# Run all tests
bundle exec rspec
# Run specific test suites
bundle exec rspec spec/models/
bundle exec rspec spec/controllers/Security Considerations
- All passwords are hashed using bcrypt
- JWT tokens are signed and verified
- CSRF protection enabled for web requests
- Secure session management with proper expiry
- Client secrets are rotatable with audit trail
- PKCE is enforced whenever
ClientApplication#require_pkce?is true (the default). Public clients cannot disable it โ a model-level validation rejects any public client saved withrequire_pkce: false. Authorize requests that omitcode_challengefor a PKCE-required client are rejected withinvalid_request. - Rate limiting on authentication endpoints
OTP Start Limits
The engine's controllers throttle their own OTP endpoints (rate_limits.api_passwordless_start_*,
login_per_*, verification_start_*). A host that drives the passwordless strategy from its own
controllers, an operation or a job (generate_passwordless_otp, StandardId::Otp.issue) never
passes those, so two limits are enforced inside the strategy itself, on the same store:
StandardId.configure do |config|
config.rate_limits.otp_start_per_target = 5 # per (realm, channel, address), per 15 minutes; 0 = off
config.rate_limits.otp_start_per_ip = 0 # per hour; off by default โ pick a ceiling that fits CGNAT/office traffic
endA tripped limit raises StandardId::RateLimitExceededError (an InvalidRequestError that the API
error handler renders as 429 rate_limit_exceeded with Retry-After, the web login page shows as a
flash, and Otp.issue returns as a failed result with error_code: :rate_limited). The per-target
default matches the API's api_passwordless_start_per_target; if you raise that (or login_per_email)
raise otp_start_per_target with it, or it becomes the binding limit.
The resend cooldown (passwordless.retry_delay) is reported separately from those limits:
StandardId::RetryDelayError (an InvalidRequestError carrying retry_after seconds), which
Otp.issue returns as error_code: :cooldown, so a host can tell "a code is already on its way"
apart from a failure. The 30-second resend
cooldown (passwordless.retry_delay) and the challenge insert run in one transaction, serialised per
address with a PostgreSQL advisory lock, so two concurrent starts cannot both issue a code.
Missing Migrations
standard_id:install:migrations copies the engine's migrations with new timestamps, so a gem migration that was never copied is invisible to Rails' own pending-migration check. StandardId checks for that itself, by migration name:
-
At boot (file-system only, no database):
config.missing_migrationsโ:warn(default in development/test: logs and prints to stderr),:raise(fails boot withStandardId::ConfigurationError), or:ignore(default in every other environment โ it never raises in production unless you opt in). -
StandardId::MigrationCheck.pending(check_database: true)returns every gem migration that is:not_installedor installed but:not_run(oneschema_migrationsread). -
Health check โ
StandardId::Checks::Migrationsis standard_health-compatible (duck-typed, no dependency). Register it non-critical so a missing migration degrades/health/readywithout failing it:c.register_check :standard_id_migrations, StandardId::Checks::Migrations, critical: false
Two cases are built in and documented in StandardId::MigrationCheck:
-
Superseded (
SUPERSEDED_BY):20260414200000_add_target_created_at_index_to_code_challengescounts as satisfied when20260416180511(whose partial, concurrently built index replaces it) is installed. -
Deferred upgrade steps (
DEFERRED_UPGRADE_STEPS):20260915000000_remove_refresh_token_lifetime_โฆis reported withseverity: :info, as a pending upgrade step to run once 0.41.1+ is deployed everywhere. It is logged at info level at boot and listed underpending_upgrade_stepsin an:okhealth result. It never warns, raises or degrades.
Any other migration you deliberately skipped goes in config.ignored_migrations, by name or original version.
Scheduled Maintenance
StandardId never deletes expired rows on its own. Four cleanup jobs do, and all four must be scheduled โ an unscheduled one lets its table grow forever (code challenges hold the plaintext OTP):
| Job | Deletes | Grace windows (perform kwargs) |
Recommended cadence |
|---|---|---|---|
StandardId::CleanupExpiredSessionsJob |
browser/device/service sessions expired > grace and holding no live refresh token (dead tokens are detached, not deleted) |
grace_period_seconds: 7 days; batch_size: 1,000 |
hourly (minute 6) |
StandardId::CleanupExpiredRefreshTokensJob |
refresh tokens expired or revoked > grace |
grace_period_seconds: 7 days |
hourly (minute 3) |
StandardId::CleanupExpiredAuthorizationCodesJob |
OAuth authorization codes expired > 7 days or consumed > 1 day |
grace_period_seconds:, consumed_grace_period_seconds:
|
hourly (minute 9) |
StandardId::CleanupExpiredCodeChallengesJob |
OTP code challenges expired > 7 days or used > 1 day |
grace_period_seconds:, used_grace_period_seconds:
|
hourly (minute 13) |
Retention is bounded by the grace windows, not the cadence. Three of the jobs are a single DELETE, so running hourly keeps that statement small on busy tables (daily is fine for small apps). CleanupExpiredSessionsJob instead works in batches of batch_size: sessions: one transaction per batch, holding a SELECT โฆ FOR UPDATE SKIP LOCKED on the candidates, an UPDATE detaching their dead refresh tokens and the DELETE. It never deletes a session whose refresh token is still live, because refresh tokens outlive session expiry by design; such a session goes on a later run, once its tokens are dead. Stagger the jobs off minute 0.
rails g standard_id:install adds all four to config/recurring.yml (Solid Queue) under production: when that file exists (--skip-recurring to opt out; re-running is a no-op). An engine cannot register Solid Queue recurring tasks itself โ Solid Queue reads one schedule file โ so existing apps should paste this under their production: key:
standard_id_cleanup_expired_sessions:
class: StandardId::CleanupExpiredSessionsJob
schedule: every hour at minute 6
standard_id_cleanup_expired_refresh_tokens:
class: StandardId::CleanupExpiredRefreshTokensJob
schedule: every hour at minute 3
standard_id_cleanup_expired_authorization_codes:
class: StandardId::CleanupExpiredAuthorizationCodesJob
schedule: every hour at minute 9
standard_id_cleanup_expired_code_challenges:
class: StandardId::CleanupExpiredCodeChallengesJob
schedule: every hour at minute 13One entry, one cron monitor (0.43+). StandardId::CleanupAllJob runs all four inline; a failure in one does not skip the rest, and the first error is re-raised afterwards so the run still fails. Schedule it instead of the four when you want a single recurring entry โ e.g. to put the schedule under one Sentry cron monitor (the gem's jobs carry none). Subclass it to attach the monitor:
# app/jobs/standard_id_cleanup_job.rb
class StandardIdCleanupJob < StandardId::CleanupAllJob
include Sentry::Cron::MonitorCheckIns
sentry_monitor_check_ins slug: "standard-id-cleanup",
monitor_config: Sentry::Cron::MonitorConfig.from_crontab("7 * * * *", checkin_margin: 5, max_runtime: 10)
end standard_id_cleanup:
class: StandardIdCleanupJob
schedule: every hour at minute 7Rake wrappers (standard_id:cleanup:all, :sessions, :refresh_tokens, :authorization_codes, :code_challenges) run the same jobs inline. See docs/OPERATIONS.md for sidekiq-cron, whenever and system-cron examples.
Contributing
- Fork the repository
- Create your feature branch (
git checkout -b feature/amazing-feature) - Write tests for your changes
- Ensure all tests pass (
bin/rspec) - Commit your changes (
git commit -am 'Add amazing feature') - Push to the branch (
git push origin feature/amazing-feature) - Open a Pull Request
License
The gem is available as open source under the terms of the MIT License.