I verified database-level ACL by db= in Amazon ElastiCache for Valkey 9.1

I verified database-level ACL by db= in Amazon ElastiCache for Valkey 9.1

Amazon ElastiCache has added support for Valkey 9.1, and it is now possible to restrict which logical databases can be accessed via ACLs. I am checking the point that writing db= raises the user's MinimumEngineVersion to 9.1, which commands are denied, and how far this can be used as a means of tenant isolation.
2026.08.20

This page has been translated by machine translation. View original

Hello. This is Takeda from the Service Development Department.

In Valkey 9.1, per-database permissions were added to ACL. By writing something like db=0,1 in the access string, you can restrict which logical databases a user can access. Amazon ElastiCache added support for Valkey 9.1 on June 23, 2026.

https://aws.amazon.com/about-aws/whats-new/2026/06/amazon-elasticache-valkey-9-1/

However, ElastiCache does not accept ACL SETUSER. Since the entry point for configuration differs from self-managed setups, I investigated how far this can be used as an isolation method for assigning logical databases per tenant.

Test Environment

I created one replication group each for Valkey 9.0 and 9.1 in the Tokyo region. The 9.0 side is used only for version compatibility checks.

Item Setting
Node type cache.m7g.large × 1 node
Cluster mode Disabled
Replicas None
Encryption in transit Enabled (required for RBAC)
Parameter group Custom group shared between 9.0 and 9.1

Both 9.0 and 9.1 use the valkey9 parameter group family, so the same parameter group can be assigned to both.

The client is an EC2 instance in the same AZ, with valkey-cli 9.1.0 built from source with TLS support.

$ valkey-cli --tls -h master.vk91blog-91... --user nodbtestuser --pass ... INFO server
valkey_version:9.1.0
redis_version:7.2.4
mem_allocator:jemalloc-5.3.0

The number of logical databases is determined by the databases setting in the parameter group. Since CONFIG GET cannot be used in ElastiCache, the value must be checked on the parameter group side.

$ valkey-cli ... CONFIG GET databases
ERR unknown command 'CONFIG', with args beginning with: 'GET' 'databases'

In this case, the default value of 16 was used.

When cluster mode is enabled, the number of databases is determined by a separate parameter called cluster-databases. According to describe-engine-default-parameters, the default value is 1, with allowed values of 1 to 10000.

$ aws elasticache describe-engine-default-parameters --cache-parameter-group-family valkey9
cluster-databases = 1 | AllowedValues: 1-10000 | IsModifiable: True

If the default of 1 is left as is, there is only one database, so writing db= provides nothing to separate. To use db= with cluster mode enabled, you would first need to explicitly set cluster-databases to 2 or more.

Creating a User with db= in ElastiCache

The procedure for configuring db= itself differs from self-managed Valkey.

ACL SETUSER Is Not Available

The Valkey documentation shows an example like ACL SETUSER alice on +@all ~* db=0,1 nopass. However, ElastiCache does not accept write-type ACL commands. Only read-type commands such as ACL LIST and ACL WHOAMI are available.

Instead, write db=0,1 in the --access-string of create-user. The --access-string is a string that lists ACL rules as they would be passed to ACL SETUSER, but passwords cannot be included. Passwords are passed separately via --passwords.

aws elasticache create-user \
  --user-id valkey91-dbtest \
  --user-name dbtestuser \
  --engine VALKEY \
  --passwords "Str0ngPassw0rdForTest1234" \
  --access-string "on ~* +@all db=0,1"

For VALKEY engine users, either a password or IAM authentication is required.

Writing db= Raises the MinimumEngineVersion

Looking at the response from the created user, some fields that were not specified have changed.

{
    "UserId": "valkey91-dbtest",
    "AccessString": "on ~* resetchannels +@all db=0,1",
    "MinimumEngineVersion": "9.1"
}

When created without db=, the MinimumEngineVersion was 7.2. It appears that the engine version requirement is inferred from the access string syntax.

Here are the results from creating several users and comparing them.

Access string MinimumEngineVersion
on ~* +@all 7.2
on ~* +@all db=0,1 9.1
on ~* +@all db=1 9.1
on ~tenant:* +@all db=0,1 9.1
on ~* &* +@all db=1 9.1

The result does not change even when combined with key patterns or channel specifications. If db= is present, it is 9.1. Since a user group inherits the maximum value of its members, a group containing even one user with db= becomes 9.1.

This value determines which clusters a user group can be associated with. I tried specifying a group containing a user with db= and creating a 9.0 replication group with create-replication-group, but it was rejected as expected.

An error occurred (InvalidParameterValue) when calling the CreateReplicationGroup operation:
User group(ug-vk91) has user(s) with access string version 9.1.0 that are not compatible
with this replication group. Please upgrade the replication group.

How It Appears in ACL LIST

Running ACL LIST on the 9.1 cluster shows whether db= is present or not.

user db1onlyuser  on ... ~*        resetchannels db=1   +@all
user dbkeyuser    on ... ~tenant:* resetchannels db=0,1 +@all
user dbtestuser   on ... ~*        resetchannels db=0,1 +@all
user default      off ...          &*            alldbs -@all
user nodbtestuser on ... ~*        &*            alldbs +@all

Users without restrictions are displayed as alldbs. This is the counterpart notation to db=.

The channel column is also separated. The three users with db= show resetchannels even though no channel was specified, while nodbtestuser without db= shows &*. It appears that resetchannels is only included when db= is specified.

What Operations Does db= Affect?

The commands in the following sections are executed by users associated with the 9.1 cluster.

SELECT and Cross-DB Operations

First, let's try SELECT with a db=0,1 user.

SELECT 0  -> OK
SELECT 1  -> OK
SELECT 2  -> NOPERM No permissions to access database
SELECT 15 -> NOPERM No permissions to access database

Unauthorized databases are rejected. This is as expected.

Cross-database operations are not limited to SELECT. I tested various operations with the same user.

Operation Result
MOVE src:a 1 (to permitted DB) 1
MOVE src:b 2 (to prohibited DB) NOPERM
COPY src:b c1 DB 0 (to permitted DB) 1
COPY src:b c2 DB 3 (to prohibited DB) NOPERM
SWAPDB 0 1 (both permitted) OK
SWAPDB 1 2 (one is prohibited) NOPERM
FLUSHDB (within permitted DB) OK
FLUSHALL NOPERM

SWAPDB is rejected if even one of the databases involved is prohibited. FLUSHALL operates on all databases, so it cannot be executed by a user who only has permission for some databases. On the other hand, FLUSHDB targets only the current database, so it can be executed.

Note that SWAPDB is a command available only when cluster mode is disabled.

Key Patterns and DB Permissions Are AND Conditions

Let's test with a user who has ~tenant:* db=0,1.

Operation Result
SET tenant:x in DB0 OK
SET tenant:y in DB1 OK
SET other:z in DB0 NOPERM No permissions to access a key
SET tenant:w in DB2 NOPERM No permissions to access database

Both conditions must be satisfied for a command to execute. Since the error messages distinguish between key violations and DB violations, you can tell which condition caused the rejection.

Key patterns are not specified per database; they apply equally to all permitted databases. If you want to apply different key patterns per database, you would use ACL selectors in combination, but that was not verified this time.

If DB0 Is Not Permitted, the Connection Is Immediately Unusable

Let's create a user with only db=1 permitted and try connecting.

PING                    -> PONG
GET immediately after connection (on DB0) -> NOPERM No permissions to access database
GET after SELECT 1      -> Normal
SELECT 0                -> NOPERM

Authentication itself succeeds and PING returns a response. However, since the connection always starts on DB0, commands will be rejected without an explicit SELECT.

On the application side, send an explicit SELECT after connecting. If the client supports specifying a DB number in the connection string, that is sufficient; otherwise, insert SELECT via a connection establishment hook or similar mechanism.

Note that with valkey-cli 9.1.0 used this time, even if the SELECT via -n 2 fails, the connection is not terminated. It displays SELECT 2 failed: NOPERM ... and then executes subsequent commands on DB0. Even if you intended to specify a prohibited DB, the write destination is DB0. This is client implementation behavior, not a server-side fallback.

Out-of-Range DB Numbers Can Be Specified

Since databases is 16, valid DB numbers are 0 to 15, but you can still create a user with out-of-range numbers.

aws elasticache create-user ... --access-string "on ~* +@all db=0,99999"
# Succeeds

ElastiCache users are independent resources from replication groups. Even if you delete a cluster, the users and user groups remain, and consistency with the databases value of the target cluster is not validated at creation time.

When connecting with this user, the runtime behavior is as follows.

Operation Result
SELECT 0 OK
SELECT 15 (in range, not permitted) NOPERM No permissions to access database
SELECT 20 (out of range) ERR DB index is out of range
SELECT 99999 (out of range) ERR DB index is out of range

For SELECT, the DB number range check is performed before the DB ACL check. The permitted number 99999 returns a range error rather than a permission error.

Since a typo resulting in an out-of-range number will not be caught during user creation, it is safer to validate the access string on the building side.

What db= Cannot Isolate

What db= protects is only the key space of logical databases. Everything else remains shared on the same node.

Pub/Sub Is Not Isolated by db=

Pub/Sub channels exist outside the key space and are therefore not subject to db=. The Valkey documentation also states "Publishing on db 10, will be heard by a subscriber on db 1."

https://valkey.io/topics/pubsub/

Let's actually test cross-DB messaging. I prepared a user with &* and db=1 on the subscriber side. That user enters DB1, runs SUBSCRIBE cross, and from a separate terminal, an unrestricted user runs PUBLISH cross "hello-from-db0" on DB0.

The return value of PUBLISH was 1, and the subscriber received message / cross / hello-from-db0. This means a message published on DB0 was delivered to a subscriber on DB1.

That said, users created with db= come back with resetchannels, meaning they have no channel permissions. Channels cannot be used as-is. The gap appears when &* is added. If you want to separate channels per tenant, restrict them with a pattern like &tenant1:* rather than &*.

Resources and Failure Scope Are Shared

Separate from authorization, there are things that cannot be separated at the DB level.

  • Since the same node is shared, memory and CPU contention cannot be avoided
  • If a node goes down, all DBs are affected
  • maxmemory is per node, and capacity is also shared

db= strengthens the authorization boundary; it is not a replacement for independent clusters. If you want to separate performance or availability per tenant, you will need to separate the clusters themselves.

Rejections Appear in Dedicated CloudWatch Metrics

I checked the rejections generated during testing in CloudWatch. The period is one day on the test date, retrieved with Period 86400 seconds and Statistic Sum.

Metric Sum Corresponding rejection
DatabaseAuthorizationFailures 10 db= violations: 10 times
KeyAuthorizationFailures 1 ~tenant:* violation: 1 time
ChannelAuthorizationFailures 1 SUBSCRIBE by resetchannels user: 1 time
CommandAuthorizationFailures 0 None
AuthenticationFailures 0 None

Rejections are split into separate metrics by type, and the counts matched the number of rejections generated during testing. DatabaseAuthorizationFailures is dedicated to DB permission violations, and the documentation states it is "available in Valkey 9.1 and later."

If this metric starts increasing after an application release, suspect a missing SELECT or a misconfigured DB number. Setting up an alarm makes it easier to notice.

Summary

The db= setting is entered through --access-string in create-user. Writing db= automatically raises the user's MinimumEngineVersion to 9.1, and user groups containing that user become exclusive to 9.1 clusters.

At runtime, not only SELECT but also MOVE, COPY ... DB, SWAPDB, and FLUSHALL are blocked. Designing without DB0 permission requires handling on the client side, and out-of-range DB numbers are not rejected at user creation time. Rejections appear in DatabaseAuthorizationFailures, so monitoring is straightforward to set up.

Viewed as an isolation method per tenant, it works solidly as an authorization boundary for the key space. However, Pub/Sub is not subject to db=, and memory and failure scope remain shared. If you want to separate performance and availability as well, the decision will be to separate the clusters themselves.

Share this article