0.0
The project is in a healthy, maintained state
StandardId is an authentication engine that provides a complete, secure-by-default solution for identity management, reducing boilerplate and eliminating common security pitfalls.
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
 Dependencies

Development

Runtime

~> 3.1
>= 2.7, < 4
>= 8.1
 Project Readme

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 install

Quick Start

1. Generate Configuration

rails generate standard_id:install

This 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"
end

3. 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
end

4. 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
end

5. 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
end

Configuration

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
  # }
end

default_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
end

Two 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
end

Refresh 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]
end

Access 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 carried

scope_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}"
    }
  }
end

Resolvers 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
  }
end

StandardId::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 InvalidScopeError

An 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]
end

protected_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
  }
end

dynamic_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 (from require "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]
    }
  }
end

social_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 elsewhere

Which 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]
  )
end

Inertia.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

  1. Add the inertia_rails gem to your Gemfile:
gem "inertia_rails"
  1. Enable Inertia in your StandardId configuration:
StandardId.configure do |config|
  config.use_inertia = true
  config.inertia_component_namespace = "auth" # Optional, defaults to "standard_id"
end

Or 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
end

A 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.

  1. 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 useForm hook from @inertiajs/react automatically handles CSRF tokens. When you call post(), put(), patch(), or delete(), 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
end

This 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
end

Set 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 .code method
  • expires_at - When the code expires
  • realm - The OTP realm ("authentication" for sign-in)
  • skip_sender - true for Otp.issue(delivery: :manual); don't deliver
  • delivery - The Otp.issue delivery mode (:built_in / :custom / :manual), nil otherwise

Note: passwordless_email_sender / passwordless_sms_sender were 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
end

connection: 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
end

Delivery 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
end

This 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}")
end

Audit 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:migrate

Wiring 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
  }
end

That'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

  1. 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
end

For 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
  1. Include the concern in your account model:
class User < ApplicationRecord
  include StandardId::AccountStatus
  # ...
end

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

  • ApiEngine resource routes (/api/v1/sessions, /api/v1/userinfo, โ€ฆ) answer 401 with error: "invalid_token" plus a WWW-Authenticate challenge, per RFC 6750 ยง3.1 โ€” the caller presented an access token and must be told to discard it. StandardId::Api::BaseController descends from ActionController::API, so your rescue_from is not in its ancestry and could never have covered it.
  • ApiEngine OAuth protocol routes (POST /api/oauth/token, /introspect, /revoke, /register) answer 400 with error: "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. The error_description is 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_reason is never surfaced.
  • WebEngine routes (/sessions, /account, /logout) are covered by the rescue_from you write on ApplicationController below, because StandardId::Web::BaseController descends 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 ApplicationController handler (it covers your pages and the web engine's), and write the Api::BaseController handler only for your own API controllers.

โš ๏ธ Never guard a rescue_from with a rescue_handlers check. 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) { ... }
end

When a superclass in the gem gains a handler for that class โ€” as Api::BaseController did 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 resolves rescue_from handlers most-recently-registered-first, so a later registration already wins over the gem's without any guard. This is exactly how one consumer's 400 invalid_grant token-endpoint shim was defeated by the gem's 401 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
end

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

Account 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

  1. 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
  1. Include the concern in your account model:
class User < ApplicationRecord
  include StandardId::AccountLocking  # For admin locking
  include StandardId::AccountStatus   # Optional: for activation/deactivation
  # ...
end

Usage

# 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 in

Handling 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
end

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

Event 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_query

Which 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") }
end

Social 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_query

How a social login finds its account (0.44+)

  1. Provider + subject. A login whose (provider, sub) is stored in standard_id_social_identities signs in to that account. The email the provider reports is not consulted.

  2. 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 emitting SOCIAL_LINK_BLOCKED) when one does not. The error's reason says which:

    • link_strategy allows it (:link_required under :strict). Under :strict, a provider whose trusted_for_linking? returns true may 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 sub from the same provider (:subject_mismatch);
    • the provider reports the email as verified: email_verified is true or the string "true" (:email_unverified). This applies under :trust_provider too.

    A successful link stores the sub, so the next login matches on step 1. If a concurrent login commits the same sub for a different account while this one writes its link, the login fails with a retryable invalid_grant (StandardId::SocialLinkConflictError) and SOCIAL_LINK_BLOCKED is published with reason :subject_conflict.

  3. New account. Otherwise a new account is created, as before, and the sub is 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_verified as true / "true" (:email_unverified otherwise);
  • the existing email identifier is verified (verified_at set). 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 sub from the same provider (:subject_mismatch);
  • trusted_for_linking? returns exactly true (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"
  }
end

Return 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 own PASSWORDLESS_CODE_GENERATED subscriber with code_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_code is accepted as the code (as everywhere Otp.verify runs); after_email_verified then receives no name.

  • 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 202 and sends nothing.

  • Login answers unverified (403) only when the password is right; everything else (unknown address, wrong password, deactivated or locked account) is one invalid_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_verified runs then) and revokes every session and refresh token. It is single-use and expires with PasswordCredential's password_reset token.

  • Existing accounts: sign-up for an address with a verified password account answers 409 account_exists, and for a social-only account 409 social_account. Set c.email_password.reveal_existing_accounts = false to answer those like a new address instead and email the owner (AccountNoticeMailer#existing_account).

  • login_method_policy is consulted with flow: :api_email_password.

  • Hooks and events. ACCOUNT_CREATED and CREDENTIAL_PASSWORD_CREATED fire at sign-up, for an address that is not verified yet; provision anything that should wait for a proven address from after_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's signup_source is "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_SUCCEEDED is 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
end

Primitives

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-set exp (caller-supplied exp wins)
  • Arbitrary JWT headers via **extra_headers (e.g. kid:)
  • allowed_audiences: to enforce the aud claim
  • key: as a single value or an Array โ€” keys are tried in order, so rotation is a one-liner
  • Failure raises StandardId::InvalidTokenError (with subclasses ExpiredTokenError, InvalidSignatureError, InvalidAlgorithmError, InvalidAudienceTokenError) โ€” no nil returns

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, when c.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
end

Required 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's iss parameter (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 whose supports_pkce? returns true. 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 passes code_challenge: and code_challenge_method: "S256" to authorization_url (build_authorization_url emits both). At the web callback the stored verifier comes back as code_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 ...
end

The 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
end

Notes:

  • Multiple schema.draw calls 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
end
sign_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 with require_pkce: false. Authorize requests that omit code_challenge for a PKCE-required client are rejected with invalid_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
end

A 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 with StandardId::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_installed or installed but :not_run (one schema_migrations read).

  • Health check โ€” StandardId::Checks::Migrations is standard_health-compatible (duck-typed, no dependency). Register it non-critical so a missing migration degrades /health/ready without 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_challenges counts as satisfied when 20260416180511 (whose partial, concurrently built index replaces it) is installed.
  • Deferred upgrade steps (DEFERRED_UPGRADE_STEPS): 20260915000000_remove_refresh_token_lifetime_โ€ฆ is reported with severity: :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 under pending_upgrade_steps in an :ok health 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 13

One 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 7

Rake 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

  1. Fork the repository
  2. Create your feature branch (git checkout -b feature/amazing-feature)
  3. Write tests for your changes
  4. Ensure all tests pass (bin/rspec)
  5. Commit your changes (git commit -am 'Add amazing feature')
  6. Push to the branch (git push origin feature/amazing-feature)
  7. Open a Pull Request

License

The gem is available as open source under the terms of the MIT License.