All skills

Craft CMS 5 plugin and module development — extending Craft with PHP. Covers elements, element queries, services, models, records, controllers, migrations, queue jobs, console commands, field types, native fields, events, behaviors, Twig extensions, widgets, filesystems, permissions, project config, GraphQL, testing, and debugging. Triggers on: beforePrepare()/afterSave()/defineSources()/defineTableAttributes()/attributeHtml(), MemoizableArray, BaseNativeField, EVENT_REGISTER_*/DEFINE_*/BEFORE_*/AFTER_*, CraftVariable, custom element or field type (normalizeValue/serializeValue/inputHtml), webhook, API endpoint, queue/batch job, CP section, dashboard widget, utility page, element action/exporter/condition, registerUserPermissions, requirePermission vs requireAdmin, kebab-case permission handles, allowAdminChanges, canView/canSave/canDelete, defineRules, elevated session, project-config/apply, drafts/revisions, element edit sidebar (EVENT_DEFINE_SIDEBAR_HTML) + toolbar buttons, metaFieldsHtml, VueAdminTable, GeneralConfig, getIsMultiSite/refreshSites stale after creating a site, deleteSite phantom sites, element query stops filtering by siteId, naive UTC datetime columns and strtotime drift, element-query ['like'] tuple matches nothing, configWarning config-file overrides. Always use when writing, editing, or reviewing Craft plugin/module PHP — even when no API is named. For plugin-specific work also load craft-plugins. Do NOT trigger for front-end Twig (craft-site) or content modeling (craft-content-modeling).

Use this Skill: https://skilld.dev/gh/michtio/craftcms-claude-skills/craftcms

This session only. Nothing lands on disk.

referencessessions-and-auth.md

≈4.1k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Sessions & Auth Internals

How Craft CMS 5's session and authentication system works under the hood: the dual-layer session model, auth token lifecycle, session invalidation on password change, the passwordResetRequired gap, elevated sessions, and patterns for plugins that manage user auth state. For session configuration (Redis, durations, cookie settings), see config-app.md and config-general.md. For permission checks and authorization events, see permissions.md and element-authorization.md.

Documentation

Common Pitfalls

  • Assuming passwordResetRequired = true forces immediate logout — it does not. The flag is only checked during authentication (login flow), not on every request. A user with an active session continues normally until the session expires.
  • Thinking Redis/Memcached session storage replaces DB tokens — Craft maintains auth tokens in Table::SESSIONS (database) regardless of the PHP session backend. Deleting DB rows invalidates sessions even when PHP sessions live in Redis.
  • Calling Craft::$app->getUser()->logout() to invalidate other sessions — this only logs out the current user. To invalidate all sessions for a specific user, delete their rows from Table::SESSIONS.
  • Setting elevatedSessionDuration to 0 in production — this disables the password re-entry requirement for sensitive operations entirely. getHasElevatedSession() always returns true.
  • Not understanding that securityKey change invalidates everything — changing CRAFT_SECURITY_KEY invalidates all sessions, password reset tokens, and encrypted field values across all users.
  • Reading $user->lastPasswordChangeDate from an element query and getting null — UserQuery::beforePrepare() intentionally excludes security-sensitive columns (lastPasswordChangeDate, password, invalidLoginCount, lastInvalidLoginDate, verificationCode, verificationCodeIssuedDate, lastLoginAttemptIp). Query Table::USERS directly: (new Query())->from(Table::USERS)->where(['id' => $user->id])->one().
  • Assuming a vetoed impersonation rolls back cleanly — Craft sets impersonatorId before loginByUserId() and only clears it when login returns false, but Yii's login() returns !getIsGuest(), which is true for the already-authenticated actor. A plugin vetoing sign-in-as must clear it itself. See Vetoing impersonation.
  • Missing User::EVENT_BEFORE_AUTHENTICATE for password inspection — this is the only Craft 5 hook where the plaintext password is in scope (passed on the event). Plaintext is cleared after authentication completes. If your plugin needs to check the password against an external service (HIBP breach detection, policy validation), listen here. Hash inside the handler, never log the plaintext or the full hash.

Contents

Dual-Layer Session Model

Craft uses two independent layers for session management:

Layer 1: PHP Sessions

The standard PHP session mechanism, stored via whatever backend is configured:

Backend Config Location Notes
Files (default) No config needed craft\web\Session handles it
Redis config/app.web.php Requires yii2-redis package
Memcached config/app.web.php Requires yii2-memcached package
Database config/app.web.php craft\web\DbSession (5.11+; yii\web\DbSession before, which logs "headers already sent" warnings). Table::PHPSESSIONS ({{%phpsessions}}) is created by craft setup/php-session-table, not on install. See config-app.md.

PHP sessions hold transient data: flash messages, return URLs, CSRF tokens, form data. They are tied to the browser's session cookie (CraftSessionId by default).

Layer 2: Auth Tokens (Database)

Independently of PHP session storage, Craft maintains auth tokens in the {{%sessions}} table (Table::SESSIONS):

Column Type Purpose
id int Primary key
userId int FK to {{%users}}
token char(100) Unique auth token
dateCreated datetime When the session was created
dateUpdated datetime Last activity timestamp

This table is always in the database, even when Redis or Memcached handles PHP sessions. Token validation happens on every authenticated request by querying this table.

How the two layers connect

On login:

  1. Craft creates a PHP session (layer 1)
  2. Craft generates an auth token and stores it in both the PHP session and Table::SESSIONS (layer 2)
  3. The browser receives the session cookie

On every authenticated request:

  1. PHP session is restored from the cookie (layer 1)
  2. Craft reads the auth token from the PHP session
  3. Craft validates the token against Table::SESSIONS (layer 2)
  4. If the token doesn't exist in the DB → user is logged out, even if the PHP session is valid

This means: deleting rows from Table::SESSIONS is the authoritative way to invalidate sessions, regardless of the PHP session backend.

Auth Token Lifecycle

Login

When Craft::$app->getUser()->login() succeeds:

  1. Password is verified against the stored hash
  2. Auth token is generated and written to Table::SESSIONS
  3. Token is stored in the PHP session
  4. User::$authTokenTimestamp is set
  5. "Remember Me" cookie is set if requested (stores token for session restoration)

Token validation (every request)

craft\web\User::validateToken() runs on every authenticated request:

  1. Reads token from PHP session
  2. Queries Table::SESSIONS for matching userId + token
  3. If no match → forces logout
  4. If match → updates dateUpdated timestamp

Logout

Craft::$app->getUser()->logout():

  1. Deletes the current session's token from Table::SESSIONS
  2. Destroys the PHP session
  3. Clears the "Remember Me" cookie

Session expiry

Craft checks the dateUpdated column against userSessionDuration (or rememberedUserSessionDuration). Stale tokens are cleaned up by garbage collection (Gc::EVENT_RUN). The purgeStaleUserSessionDuration config setting controls how long to keep expired session rows (default: 90 days).

Session Invalidation on Password Change

When a user's password changes (either self-service or admin-initiated), Craft automatically invalidates all other sessions for that user:

  1. During User::afterSave(), Craft detects $this->newPassword is set
  2. Queries Table::SESSIONS for all rows matching the user's ID
  3. Deletes all rows except the current session's token: ['not', ['token' => $currentToken]]
  4. Result: every other browser/device is immediately logged out on next request

This is automatic — no plugin code needed for password-change invalidation.

What triggers this

  • User changes their own password via the CP profile page
  • Admin changes a user's password via their edit screen
  • Password reset flow (user clicks reset link, enters new password)
  • Programmatic: setting $user->newPassword and saving

What does NOT trigger this

  • Setting passwordResetRequired = true without changing the password
  • Changing the user's email address
  • Suspending or deactivating a user (suspension prevents new logins but doesn't kill existing sessions)
  • Changing user group membership

The passwordResetRequired Gap

passwordResetRequired is a boolean field on the User element. When set to true, it forces the user to choose a new password on their next login.

Critical detail: This flag is checked inside getAuthStatus(), which is called during authentication only (the login flow). It is NOT checked on every request via renewAuthStatus().

This means:

  • A user with passwordResetRequired = true and an active session continues using the site normally
  • The flag only takes effect when they log out and try to log in again (or their session expires)
  • To force an immediate re-authentication, you must also delete their sessions from Table::SESSIONS

Force-logout pattern for plugins

use craft\db\Table;

// Set the flag
$user->passwordResetRequired = true;
Craft::$app->getElements()->saveElement($user);

// Force immediate logout by deleting all sessions
Craft::$app->getDb()->createCommand()
    ->delete(Table::SESSIONS, ['userId' => $user->id])
    ->execute();

This is the only reliable way to force immediate re-authentication. The user's next request will fail token validation and redirect to login, where the passwordResetRequired flag takes effect.

Elevated Sessions

Elevated sessions are a second-factor gate for sensitive CP operations. After re-entering their password, the user enters an "elevated" state for a limited time.

How it works

  1. User triggers a sensitive action (change password, manage users, edit GraphQL tokens)
  2. Craft's JS layer (ElevatedSessionManager.js) shows a password re-entry modal
  3. User enters password → POST to users/start-elevated-session
  4. Craft stores the elevated session timeout in the PHP session
  5. getHasElevatedSession() returns true until the timeout expires

Configuration

Setting Default Effect
elevatedSessionDuration 300 (5 min) How long the elevated state lasts. 0 disables — getHasElevatedSession() always returns true.

Controller enforcement

// Require elevated session before sensitive action
$this->requireElevatedSession();

// This calls getHasElevatedSession() on the User component
// If not elevated, throws a 403 that the JS layer catches to show the modal

Operations that require elevation

Built-in operations that call requireElevatedSession():

  • Changing user email address
  • Changing user password
  • Managing user groups and permissions
  • Editing GraphQL schemas and tokens
  • Plugin settings changes (when $requireAdmin is true)

Plugins should call $this->requireElevatedSession() in controller actions that modify authentication state, security settings, or access controls.

Vetoing impersonation leaks impersonatorId

A plugin that gates "sign in as" (an approval workflow, a compliance rule, a break-glass audit requirement) by cancelling the login must clear impersonatorId itself. Craft's own rollback does not fire for a vetoed impersonation.

The mechanism, in UsersController::actionImpersonate():

// Save the original user ID to the session now so User::findIdentity()
// knows not to worry if the user isn't active yet
$userSession->setImpersonatorId($userSession->getId());

if (!$userSession->loginByUserId($userId)) {
    $userSession->setImpersonatorId(null);      // ← the only rollback
    // ...
    return null;
}

impersonatorId is set before the login attempt, and cleared only when loginByUserId() returns false. But loginByUserId() delegates to Yii's User::login(), which ends:

// yii\web\User::login()
if ($this->beforeLogin($identity, false, $duration)) {
    $this->switchIdentity($identity, $duration);
    // ...
    $this->afterLogin($identity, false, $duration);
}

return !$this->getIsGuest();

The return value is !getIsGuest() — not whether beforeLogin() passed. The actor performing the impersonation is already authenticated, so they are not a guest, so login() returns true whether the veto fired or not. Craft's rollback branch is unreachable in the veto case.

Result: the veto correctly prevents the identity switch, but __impersonator_id stays in the session. On the next request User::getImpersonator() resolves a real impersonator, and Craft behaves as though an impersonation is in progress — the CP shows the impersonation banner and the "return to your account" flow performs a bogus redirect.

Clear it at the veto point:

use craft\elements\User as UserElement;
use craft\web\User as UserSession;
use yii\web\UserEvent;

Event::on(
    UserSession::class,
    UserSession::EVENT_BEFORE_LOGIN,
    function(UserEvent $event) {
        if (!MyPlugin::getInstance()->getGate()->canImpersonate($event->identity)) {
            $event->isValid = false;

            // Craft only rolls this back when login() returns false — and it
            // returns !getIsGuest(), which is true for the already-authenticated
            // actor. Without this the next request thinks an impersonation is live.
            Craft::$app->getUser()->setImpersonatorId(null);
        }
    },
);

setImpersonatorId(null) calls SessionHelper::remove($this->impersonatorIdParam) — the same path Craft's own rollback uses, so it's safe to call unconditionally in the veto branch.

Session Configuration Quick Reference

For full config details, see config-general.md and config-app.md. Quick reference:

Setting Location Default Purpose
userSessionDuration general.php 3600 (1h) Active session lifetime
rememberedUserSessionDuration general.php 1209600 (14d) "Remember Me" lifetime
elevatedSessionDuration general.php 300 (5min) Elevated session window
phpSessionName general.php 'CraftSessionId' Session cookie name
requireMatchingUserAgentForSession general.php true Validate user agent on restore
requireUserAgentAndIpForSession general.php true Require UA+IP for new session
purgeStaleUserSessionDuration general.php 7776000 (90d) Cleanup of expired DB tokens
session component app.web.php DB-backed Redis/Memcached override

Plugin Patterns

Invalidating all sessions for a user

use craft\db\Table;

/**
 * Force-logout a user from all devices.
 *
 * @param int $userId
 */
public function invalidateAllSessions(int $userId): void
{
    Craft::$app->getDb()->createCommand()
        ->delete(Table::SESSIONS, ['userId' => $userId])
        ->execute();
}

Invalidating all sessions except current

use craft\db\Table;

/**
 * Force-logout a user from all devices except the current one.
 *
 * @param int $userId
 */
public function invalidateOtherSessions(int $userId): void
{
    $currentToken = Craft::$app->getUser()->getToken();

    Craft::$app->getDb()->createCommand()
        ->delete(Table::SESSIONS, [
            'and',
            ['userId' => $userId],
            ['not', ['token' => $currentToken]],
        ])
        ->execute();
}

Counting active sessions

use craft\db\Table;

/**
 * Count active sessions for a user.
 *
 * @param int $userId
 * @return int
 */
public function getActiveSessionCount(int $userId): int
{
    return (int)(new \craft\db\Query())
        ->from(Table::SESSIONS)
        ->where(['userId' => $userId])
        ->count();
}

Checking if a user has been force-reset

// Check if user needs to reset password on next login
if ($user->passwordResetRequired) {
    // User will be prompted on next login
    // But they may still have active sessions
    $hasActiveSessions = $this->getActiveSessionCount($user->id) > 0;

    if ($hasActiveSessions) {
        // Force immediate logout if needed
        $this->invalidateAllSessions($user->id);
    }
}

GDPR-safe session purge

For GDPR compliance, combine session deletion with the GC event for automatic cleanup:

use craft\services\Gc;
use yii\base\Event;

Event::on(
    Gc::class,
    Gc::EVENT_RUN,
    function() {
        // Purge sessions for deactivated users
        Craft::$app->getDb()->createCommand()
            ->delete(Table::SESSIONS, [
                'userId' => (new \craft\db\Query())
                    ->select('id')
                    ->from(Table::USERS)
                    ->where(['active' => false]),
            ])
            ->execute();
    }
);

Source: SKILL.md on GitHub

No alerts16d3 checks · Risk SAFE
  • Gen Agent Trust Hub16d

    The craftcms skill is a comprehensive reference guide for Craft CMS 5 plugin and module development. It provides extensive documentation on security best practices, including authorization layers, data handling, and common architectural pitfalls. No malicious patterns or security risks were identified.

  • Socket16d

    No alerts

  • Snyk16d

    Risk: LOW · No issues

Signed by skilld at 614a89e. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 weeks ago.

Activeupdated 2 weeks ago

README badge

README badge for michtio/craftcms-claude-skills/craftcms