BoxLang π A New JVM Dynamic Language Learn More...
RememberMe lets a ColdBox app keep a user signed in after the user's session ends.
The module creates a long-lived cookie and stores a matching token on the server. On a later visit, your app can use that token to find the user and start a new login session.
RememberMe does not check passwords or log users in by itself. Your app still needs an authentication system such as cbAuth, cbSecurity, or your own login code.
RememberMe has no required package dependencies. The default storage
provider uses queryExecute() directly.
Run this command from your ColdBox app:
box install rememberMe
Then complete the setup below.
The default storage provider saves tokens in a table named user_remember.
The following SQL creates that table in Microsoft SQL Server:
CREATE TABLE user_remember (
id INT IDENTITY(1,1) NOT NULL PRIMARY KEY,
createdDate DATETIME2 NOT NULL,
modifiedDate DATETIME2 NOT NULL,
userId INT NOT NULL,
selector VARCHAR(35) NOT NULL,
hashedValidator VARCHAR(32) NOT NULL,
ipAddress VARCHAR(45) NOT NULL,
userAgent VARCHAR(255) NOT NULL,
expirationDate DATETIME2 NOT NULL,
lastUsedDate DATETIME2 NULL
);
CREATE INDEX IX_user_remember_selector
ON user_remember (selector);
CREATE INDEX IX_user_remember_userId
ON user_remember (userId);
CREATE INDEX IX_user_remember_expirationDate
ON user_remember (expirationDate);
The repository also includes an idempotent SQL Server schema. You can run that file more than once without recreating the table.
The storage queries use standard SQL. You can use another database engine, but you may need to change the data types and identity column syntax in the setup script.
RememberMe needs a service that can load one user from a numeric ID. The service must have this method:
function retrieveUserById( required id ) {
// Return your app's user object for arguments.id.
}
Your service can implement
rememberMe.interfaces.IUserRememberService, but the
module does not require the implements attribute. The
method name and argument are the important parts.
Here is a small example:
component singleton {
function retrieveUserById( required id ) {
return entityLoadByPK( "User", arguments.id );
}
}
Use the lookup code that fits your app. The method may return an ORM entity, a CFC, or another user object that your authentication system accepts.
Add a rememberMe block to moduleSettings in
config/Coldbox.cfc. Keep any other module settings that
are already in the struct.
moduleSettings = {
rememberMe = {
userServiceClass = "UserService",
tokenEncryptKey = "replace-this-with-your-generated-key",
days = 30,
autoPurge = true,
purgeGraceDays = 1,
purgeTime = "04:00",
tokenStorageClass = "SQLTokenStorage@rememberMe",
table = "user_remember",
datasource = ""
}
};
userServiceClass is the name that WireBox uses to find
the service from the previous step. Replace UserService
with the mapping or component path used by your app.
tokenEncryptKey protects the value in the browser cookie.
Generate the key once with this CFML code:
generateSecretKey( "AES", 256 )
Save the generated value in your app's secret storage. Do not generate a new key each time the app starts. A new key makes every existing RememberMe cookie invalid.
The default datasource value is an empty string. An
empty value tells queryExecute() to use the default
datasource from your application's Application.cfc.
Call rememberMe() after your app accepts the user's
login. Only call it when the user selects your βRemember meβ checkbox.
// Your app has already checked the password and loaded the user.
auth().login( user );
if ( event.getValue( "rememberMe", false ) ) {
remember().rememberMe( user.getId() );
}
The auth() calls in this guide are cbAuth examples.
Replace those calls if your app uses another authentication system.
Calling rememberMe() does three things:
The cookie lasts for the number of days in the days setting.
Try to recall a user only when the user is not already logged in. A
ColdBox preProcess() interceptor is a good place for this
check because it runs early on each request.
function preProcess( event, interceptData, buffer, rc, prc ) {
if ( auth().isLoggedIn() || !remember().cookieExists() ) {
return;
}
try {
var user = remember().recallMe();
// Change this check if your user object uses another way to report a match.
if ( user.isLoaded() ) {
auth().login( user );
} else {
remember().forgetMe();
}
} catch ( InvalidToken exception ) {
// The cookie is damaged, expired, forged, or no longer has a matching row.
remember().forgetMe();
}
}
recallMe() returns the value from your user service's
retrieveUserById() method. RememberMe cannot tell whether
that value represents a real user. Your app must check the returned
value before starting a login session.
The service throws InvalidToken when the cookie cannot
be trusted. The example clears the bad cookie so the app does not
retry it on every request.
recallMe() can also throw MissingCookie if
no usable cookie exists. Calling cookieExists() first
normally prevents that error.
Call forgetMe() when the user logs out:
remember().forgetMe();
auth().logout();
forgetMe() deletes the token for the current browser and
expires the browser cookie. It is safe to call when no RememberMe
cookie exists.
The remember() application helper is convenient in
handlers and interceptors. You can also inject the service directly:
property name="rememberMeService" inject="RememberMeService@rememberMe";
Then call the same methods on rememberMeService:
rememberMeService.rememberMe( user.getId() );
Most apps only need rememberMe(),
cookieExists(), recallMe(), and forgetMe().
| Method | What it does |
|---|---|
rememberMe(userId)
| Creates a token and sends the browser cookie. The user ID must be numeric. |
cookieExists()
| Returns true when the current request has a
non-empty RememberMe cookie. |
recallMe()
| Validates the cookie, updates token usage data, and returns the user from your user service. |
forgetMe()
| Deletes the current browser's token and expires its cookie. |
deleteByUserId(userId)
| Deletes every stored token for one user. This prevents future recall on all devices. |
deleteAll()
| Deletes every stored token. This prevents future recall for all users. |
purgeExpired(graceDays)
| Deletes old, expired tokens. When graceDays
is omitted, the method uses purgeGraceDays. |
getCookie()
| Returns the encrypted cookie value. Call
cookieExists() first because
getCookie() throws when the cookie is missing. |
isValidToken(token)
| Checks the cookie format. This method does not check storage or confirm that the token is active. |
Deleting stored tokens does not remove cookies from other browsers.
Those cookies will fail validation on their next request, and your
recall code should clear them with forgetMe().
Deleting stored tokens also does not end sessions that are already active. Your authentication system must end those sessions if you need to log users out immediately.
| Setting | Default | Meaning |
|---|---|---|
userServiceClass
| ""
| WireBox mapping for the service that has
retrieveUserById(). You must set this value. |
tokenEncryptKey
| ""
| Secret key used to encrypt the cookie. You must set this value. |
tokenEncryptAlgorithm
| "aes"
| Algorithm used to encrypt and decrypt the cookie. |
validatorHashAlgorithm
| "MD5"
| Algorithm used to hash the token validator before storage. |
days
| 30
| Number of days before the cookie and stored token expire. |
autoPurge
| true
| Enables the daily task that deletes old token rows. |
purgeGraceDays
| 1
| Number of days to keep a token row after the token expires. |
purgeTime
| "04:00"
| Time for the daily purge task, in 24-hour server time. |
tokenStorageClass
| "SQLTokenStorage@rememberMe"
| WireBox mapping for the token storage provider. |
table
| "user_remember"
| Table used by the SQL and qb storage providers. |
datasource
| ""
| Datasource used by the SQL and qb providers. An empty value uses the app's default datasource. |
Changing the encryption key, encryption algorithm, or hash algorithm makes existing cookies invalid. Users with those cookies will need to log in again.
The example schema uses VARCHAR(32) for
hashedValidator because the default MD5 hash has 32
characters. Widen that column before you select a hash algorithm with
a longer result.
The default SQL provider allows letters, numbers, underscores, and
periods in table. A schema-qualified name such as
dbo.user_remember is valid. Quoted names such as
[user_remember] are not supported.
RememberMe creates two random values for each token: a selector and a validator.
The original validator is never sent to the storage provider. This design means that a copied token table does not contain the value that a browser must present.
The cookie uses HttpOnly and SameSite=Lax.
The module also sets the cookie's Secure flag when the
current request uses HTTPS.
RememberMe does not rotate a token after recall. A token keeps the same selector, validator, and expiration date until it expires or is deleted.
An expired token cannot recall a user. The database row still exists until a cleanup removes it.
RememberMe registers a ColdBox scheduled task named
rememberMe-purge-expired-tokens. The task runs once each
day at purgeTime. It deletes rows that have been expired
for more than purgeGraceDays.
For example, the default grace period keeps an expired row for one
extra day. Set purgeGraceDays = 0 to delete rows during
the first purge after they expire.
Set autoPurge = false to disable automatic deletion. The
task remains registered, but the task does no work.
You can also run cleanup yourself:
var rememberMeService = getInstance( "RememberMeService@rememberMe" );
// Use the configured purgeGraceDays value.
var deletedCount = rememberMeService.purgeExpired();
// Delete every token that has already expired.
var deletedNowCount = rememberMeService.purgeExpired( 0 );
purgeExpired() returns the number of deleted rows. A
custom storage provider may return 0 when its backend
cannot report a count.
In a cluster, the scheduled task runs on every server. Two servers can safely try to delete the same expired rows. A row that one server already deleted gives the other server nothing to delete.
tokenStorageClass selects where RememberMe stores tokens.
The value must be a class that WireBox can resolve.
| Provider | Storage location | Extra setup | Recommended use |
|---|---|---|---|
SQLTokenStorage@rememberMe
| A database through queryExecute()
| Create the token table | Production and most apps |
MemoryTokenStorage@rememberMe
| A struct in the app's memory | None | Local development and tests |
QBTokenStorage@rememberMe
| A database through qb | Install qb and create the token table | Apps that choose to use qb |
SQLTokenStorage@rememberMe is the default. It uses the
table and datasource settings. It does not
require qb or another package.
To use memory storage, change one setting:
tokenStorageClass = "MemoryTokenStorage@rememberMe"
Memory storage is useful when you want to try the module before creating a database table.
Do not use memory storage in production. Every application restart deletes all tokens. Each server in a cluster also gets a separate token store. A cookie created on one server will not work on another server.
RememberMe includes a storage provider for qb, but RememberMe does not install qb.
Install qb in your app:
box install qb
Then change the provider:
tokenStorageClass = "QBTokenStorage@rememberMe"
The qb provider uses the same table and
datasource settings as the default provider. If qb is
missing, the first storage operation throws a
MissingDependency error with setup instructions.
You can store tokens in an ORM, Redis, an API, or another backend.
Set tokenStorageClass to the WireBox mapping for your class:
tokenStorageClass = "TokenStorage"
The class must provide these seven methods. The full contract is in
interfaces/ITokenStorage.cfc
.
| Method | Required behavior |
|---|---|
create(token)
| Store userId, selector,
hashedValidator, ipAddress,
userAgent, createdDate,
modifiedDate, and expirationDate. |
getBySelector(selector)
| Return a token struct with at least userId,
selector, hashedValidator, and
expirationDate. Return an empty struct when no
token exists. |
updateUsage(selector, audit)
| Update ipAddress, userAgent,
lastUsedDate, and modifiedDate. Do not
change the token or its expiration date. |
deleteBySelector(selector)
| Delete one token. Do nothing when the token does not exist. |
deleteByUserId(userId)
| Delete every token for one user. |
deleteAll()
| Delete every token. |
deleteExpiredBefore(cutoffDate)
| Delete tokens with an expiration date before the cutoff.
Return the number deleted, or 0 if the backend
cannot report a count. |
The service passes plain strings, numbers, and date objects to storage. The service also calculates all dates before it calls storage. Your provider should store the supplied values without replacing them.
Storage receives an already-hashed validator. Storage never receives the original validator from the browser cookie.
models/MemoryTokenStorage.cfc
is the shortest complete example. models/SQLTokenStorage.cfc
shows a persistent implementation.
Make a stateful provider a singleton. For example, memory storage must be a singleton so each request uses the same in-memory struct. A provider that sends each operation to a database does not need to be a singleton.
onRecall
interception pointRememberMe announces onRecall after it validates a token
and loads the user. The interception data has two values:
| Name | Value |
|---|---|
user
| The value returned by retrieveUserById(). |
userId
| The numeric user ID stored with the token. |
Add an onRecall() method to a registered ColdBox
interceptor to listen for the event:
component {
function onRecall( event, interceptData ) {
var recalledUser = arguments.interceptData.user;
var recalledUserId = arguments.interceptData.userId;
// Add audit logging or other app-specific work here.
}
}
The event does not run when RememberMe rejects a token.
RememberMe 2.0 removed its required qb dependency. The default
provider is now SQLTokenStorage@rememberMe, which uses queryExecute().
tokenStorageClass, you do not need
to change your configuration or database. The new provider uses the
same table and columns.tokenStorageClass =
"QBTokenStorage@rememberMe", install qb in your app
with box install qb.box.json before your next install.The 2.0 storage change does not change the cookie format or database schema. Existing RememberMe tokens remain valid.
Some apps call remember() from
onSessionStart() before ColdBox has loaded application
helpers. In that case, the first request may report that
remember does not exist.
Run recall logic from a preProcess() interceptor instead
of onSessionStart(). The recall example in this guide
uses preProcess() for this reason.
RememberMe is available under the MIT License.
All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
The module no longer depends on qb. ModuleConfig.cfc dropped this.dependencies = [ "qb" ] and box.json dropped the dependency and its install path, so box install rememberMe now installs exactly one package and adds nothing to the host app's module registry. The reason is that ColdBox does not isolate module dependencies: it registers a module's own modules/ folder as real, application-wide modules, so the qb that shipped inside rememberMe was forced onto the host app and could collide with the version that app had chosen for itself.
New default storage provider: SQLTokenStorage@rememberMe (models/SQLTokenStorage.cfc). Plain queryExecute against the same table and datasource settings, with the same columns and the same behaviour as the qb provider it replaces. The SQL is deliberately dialect-neutral ANSI β no TOP/LIMIT, no bracket quoting, no vendor functions.
Breaking: the tokenStorageClass default changed from "QBTokenStorage@rememberMe" to "SQLTokenStorage@rememberMe". If you never set that setting there is nothing to do β the table, the columns, the cookies and the token scheme are all unchanged, and no user gets logged out. If you did set it to QBTokenStorage@rememberMe, that provider still ships and still works, but you must now run box install qb in your own app. If your app used qb only because rememberMe installed it, add it to your own box.json before it disappears on your next box install.
QBTokenStorage@rememberMe is now opt-in and requires the host app to install qb. The file stays in the module and stays mapped. Its qb injection moved from a build-time property name="qb" inject="provider:QueryBuilder@qb" to a lazy getQB(), so the component builds fine on an app with no qb and only fails if something actually uses it β with a MissingDependency error naming both fixes (box install qb, or switch to SQLTokenStorage).
All four engines are green β Lucee 5.4.8, Lucee 6.2.7, Adobe 2023 and BoxLang 1.15 β at 14 ModuleSpec, 55 unit and 33 integration specs. RecallSpec and PurgeSpec drive the wired service, so they exercise every SQLTokenStorage statement against a real SQL Server and assert on the rows directly.
Separately verified by hand with qb removed from the harness entirely: the module boots, all three mappings resolve, and the whole suite passes except the one spec that deliberately uses qb β which fails with the intended MissingDependency message.
MemoryTokenStorage@rememberMe (models/MemoryTokenStorage.cfc), mapped asSingleton because a transient in-memory store would be rebuilt empty on every injection. It is for development, for tests, and for trying the module out before creating the token table β tokens are lost on application restart and are not shared across cluster nodes, so it is documented as not for production. It is also the shortest complete implementation of interfaces/ITokenStorage.cfc, which makes it the file to copy when writing a custom provider.SQLTokenStorage validates the table setting against an allow-list and throws InvalidConfiguration otherwise. A table name is an identifier and cannot be a bind parameter, so it is interpolated into the SQL; qb used to pass it through its grammar's wrapValue() and raw SQL does not, so this restores that layer.SQLTokenStorageSpec.cfc (10 specs) and MemoryTokenStorageSpec.cfc (13 specs, the full contract driven directly). ModuleSpec gained assertions that the module declares no dependencies, that all three providers map, and that MemoryTokenStorage really is a singleton. CustomStorageSpec now exercises the datasource option on both SQL-backed providers.test-harness/box.json as a harness dependency, installed into test-harness/modules/qb, so the QBTokenStorage specs keep running against the real thing.tokenStorageClass setting (a WireBox DSL, mirroring userServiceClass). The default, QBTokenStorage@rememberMe (models/QBTokenStorage.cfc), is the same qb code as before β public API and out-of-the-box behaviour are unchanged. The contract lives in interfaces/ITokenStorage.cfc; providers receive plain values only, and never see a raw validator (all crypto stays in the service).tokenStorageClass (default "QBTokenStorage@rememberMe"), table (default "user_remember" β the previously hardcoded table name), and datasource (default "" = the application default from your Application.cfc, passed per-query via qb's options).QBTokenStorageSpec.cfc, new integration bundle CustomStorageSpec.cfc (full lifecycle against an in-memory provider, plus datasource-option plumbing), and a harness StubTokenStorage.cfc that implements the shipped interface to prove it is satisfiable.config/Scheduler.cfc, registered as cbScheduler@rememberMe) runs daily at purgeTime and deletes rows whose expirationDate passed more than purgeGraceDays days ago. Enabled by default; set autoPurge = false to disable (the task stays registered but no-ops). Expired rows were already unusable β recallMe() rejects them β this is table hygiene.purgeExpired( numeric graceDays ) returning the number of rows deleted, for manual/host-app-scheduled cleanup.autoPurge (default true), purgeGraceDays (default 1), purgeTime (default "04:00", server time).IX_user_remember_expirationDate in the canonical schema (test-harness/tests/resources/schema.sql), added idempotently for existing databases.PurgeSpec.cfc plus ModuleSpec assertions for the scheduler, task, and settings defaults.beforeAll(). All bundles in a runner request share one request, and ColdBox 7's WireBox memoises transient dependencies there (request.cbTransientDICache) β so restarting mid-request left later bundles' rebuilt transients wired to the previous boot's shut-down services. The visible symptom was onRecall announcements that no registered interceptor ever heard, in multi-bundle runs only. Latent until 1.3.0 added a second integration bundle. See AGENTS.md trap 6.The suite is now green on all four engines (Lucee 5, Lucee 6, Adobe 2023, BoxLang 1). The 1.2.0 "Known issues" entry below is resolved:
rememberMe() is now a portable cfcookie() call with a DateTime expires instead of a Lucee-only attribute-struct assignment to the cookie scope. This fixes every rememberMe() call erroring on BoxLang (Can't cast [30] to a DateTime). path="/" is set on all engines except Adobe, whose cfcookie refuses path without domain β ACF defaults its cookies to Path=/ anyway.cookieExists() now treats an empty cookie value as absent. Adobe CF never removes an expired/deleted cookie's key from the in-request cookie scope β it leaves it behind with an empty value β so after forgetMe(), recallMe() on ACF threw InvalidToken where it should throw MissingCookie. An empty token is unusable regardless of engine, so "empty means missing" is the honest semantic everywhere.forgetMe() uses structDelete() instead of the member-function form cookie.delete().Fixed: the validator half of the selector/validator scheme was dead code. Two bugs cancelled each other out, so nothing looked broken:
parseToken() re-hashed an already-hashed value, so the parsed validator could never equal the stored one.isMatch() was inverted β compare() returns 0 when strings are equal, so the function returned true when they differed.The net effect was that the validator comparison in recallMe() never rejected anything. Any decryptable cookie whose selector matched a database row would authenticate, regardless of its validator. The encryption key was the only real secret.
rememberMe() now stores the hashed validator in the database and puts the raw validator in the cookie β the canonical scheme, where a stolen database yields hashes an attacker cannot present back. isMatch() compares correctly.
Breaking: existing remember-me cookies will no longer validate. They are rejected as InvalidToken, which the documented consumer pattern already catches and handles by calling forgetMe(). Users will be logged out once on deploy.
rememberMe() did not populate modifiedDate on INSERT, but the documented schema has that column as NOT NULL with no default β so the module could not write a row to its own schema. It now sets modifiedDate at creation.test-harness/ with unit and integration suites (46 specs). See AGENTS.md for how to run them, and for the per-engine status matrix.qb is now declared as a dependency in box.json. ModuleConfig.cfc has always declared this.dependencies = [ "qb" ], but box install rememberMe never actually installed it.rememberMe() assigns a struct of cookie attributes to the cookie scope, which is Lucee-specific. The suite is green on Lucee 5 and 6, and fails on Adobe 2023 (4 specs) and BoxLang (16 specs) because of it. See AGENTS.md for detail.onRecall, to interceptor settings in the module configuration. This interceptor fires when the remember().recall() method is called, allowing for custom logic to be executed during the recall process (like logging).retrieveUserById() instead of getUserById().
$
box install rememberMe