A fresh Valkey 9.1 server trusts whoever can reach it. The default user is on nopass ~* &* +@all, so only the listening address and protected mode stand between a stranger and FLUSHALL, and the official Docker image compiles protected mode off. Hardening closes that gap, and it usually announces itself with errors such as NOPERM No permissions to access a key, which do not say which rule denied the call. This guide covers Valkey 9.1.3, the current patch as of November 2026: network exposure, least-privilege ACL users with the 9.1 per-database rules, TLS and certificate rotation, what an expired certificate does on 9.1.x, LDAP through valkey-ldap, building without Lua, and debugging denials with ACL LOG and ACL DRYRUN.
- Check what is listening first: a server started without a config file binds every interface, and the official
valkey/valkeyimage compiles protected mode off. - Turn the default user off and give every application, replica, Sentinel, and monitoring agent its own user with a key prefix, a
db=list, and no@adminor@dangerouscommands, on 9.1.2 or later. - Valkey 9.1.x loads expired certificates without complaint, although the TLS page says otherwise (the check was reverted in #3572). Alert on
INFO tlsand settls-auto-reload-interval. - Lua has no runtime switch in 9.1. Remove it with
make BUILD_LUA=no, after an inventory shows no script traffic. - A NOPERM message often hides the key or database.
ACL LOGandACL DRYRUNname them.
Scope and review date: November 2026. This post covers self-managed Valkey 9.1 (9.1.3, released 26 October 2026), with notes where 9.0, 8.x, or the 9.2 release candidates differ. Defaults and behavior were checked on 10 November 2026 against the Valkey documentation, the 9.1.3 valkey.conf and source, the release notes, the official container build files, and the valkey-ldap repository. The commands were not run in a lab, so rehearse them on staging. Managed services are out of scope.
How exposed is a default Valkey 9.1 server?
It depends on how the server was started:
- With the shipped
valkey.conf:bind 127.0.0.1 -::1andprotected-mode yes, so only local clients connect. - With no config file: the compiled-in default is
bind * -::*, every interface. Protected mode is still on. - With the official Docker image: the
valkey/valkeybuild patchesconfig.csoprotected-modedefaults tono, because Docker publishes no ports unless asked.valkey-bundleis built on it. In Kubernetes or with host networking, such a container accepts every client that can route to it.
The Valkey administration page still says the server "listens to all the network interfaces" with no authentication by default, which describes only the no-config case. Check the running server instead:
valkey-cli -h 127.0.0.1 CONFIG GET bind
valkey-cli -h 127.0.0.1 CONFIG GET protected-mode
valkey-cli -h 127.0.0.1 ACL GETUSER default
ss -ltnp | grep valkeyInterpretation: * -::* means every interface. If the default user has the nopass flag and protected mode is no, every client that reaches a listening address is fully authenticated without sending AUTH.
Protected mode is a safety net, not a control: it applies only while the default user has nopass. Once a password is set, the server takes AUTH attempts from any address it listens on. Bind to loopback plus the one private interface clients use, for example bind 127.0.0.1 -::1 10.0.4.15, and filter the port with security groups or a NetworkPolicy. Pre-authentication reachability has had its own bug: CVE-2025-21605, fixed in 8.1.1, 8.0.3, and 7.2.9, let unauthenticated clients grow output buffers without a limit.
A default Valkey 9.1 server next to a hardened one
Switch tabs to see which settings this guide changes
Default 9.1
| Setting | Default 9.1 |
|---|---|
| Listening addresses | Loopback with valkey.conf; all interfaces without one |
| Protected mode | On; off in the official Docker image |
| Default user | on nopass ~* &* +@all |
| Application users | None |
| Client connections | Plain TCP on 6379 |
| Replication and cluster bus | Plain TCP |
| Certificate reload | Off (tls-auto-reload-interval 0) |
| Scripting engine | Lua linked in and loaded at startup |
| DEBUG and MODULE commands | Blocked |
Hardened 9.1
| Setting | Hardened 9.1 |
|---|---|
| Listening addresses | Loopback plus one private interface, firewalled |
| Protected mode | On, kept as a safety net |
| Default user | off, no password, no commands |
| Application users | One per service: key prefix, db=, no @dangerous |
| Client connections | TLS only: port 0, tls-port 6379 |
| Replication and cluster bus | tls-replication yes, tls-cluster yes |
| Certificate reload | Interval set, plus an INFO tls alert |
| Scripting engine | BUILD_LUA=no when no client uses scripts |
| DEBUG and MODULE commands | Blocked |
Already safe: enable-debug-command, enable-module-command, and enable-protected-configs default to no and can only change at startup. The open default user is the setting that matters most.
How should you design ACL users for applications?
One user per service, built up from nothing. Start with -@all, add the categories the service needs, and remove @dangerous last; rules apply left to right. Add the service's key prefix, resetchannels unless it uses Pub/Sub, and on 9.1 a db= list. Keep users in an external ACL file:
# /etc/valkey/users.acl (Valkey 9.1; earlier versions do not know db=)
user default off resetpass resetkeys resetchannels -@all
user admin on #REPLACE_WITH_SHA256 ~* &* +@all
user orders on #REPLACE_WITH_SHA256 resetchannels ~orders:* ~cart:* db=0 -@all +@read +@write +@connection +@transaction -@dangerous
user reports on #REPLACE_WITH_SHA256 resetchannels %R~orders:* db=0 -@all +@read +@connection -@dangerousInterpretation: the trailing -@dangerous strips FLUSHALL, FLUSHDB, KEYS, SORT, and the rest of that category from what +@write added. %R~ makes reports read-only on the order keys. The # form stores a SHA-256 hash, so the file holds no plain passwords.
PASS=$(valkey-cli ACL GENPASS) # 64 hex characters from the system CSPRNG
printf '%s' "$PASS" | sha256sum # value for the #REPLACE_WITH_SHA256 rule
valkey-cli ACL LOAD # reload users.acl; one bad line rejects the file
valkey-cli ACL SAVE # write runtime ACL SETUSER changes backInterpretation: per the Valkey ACL documentation, a failed ACL LOAD keeps the old users, and CONFIG REWRITE does not call ACL SAVE. aclfile is immutable, so moving users out of valkey.conf needs one restart.
Three behaviors catch teams. New connections land in database 0 even when the user cannot use it, so set the database in the client's connection options. FLUSHALL, CLUSTER MIGRATESLOTS, and CLUSTER CANCELSLOTMIGRATIONS need alldbs. And a user or selector without a db= rule starts with alldbs.
Before 9.1.2, 9.0.6, 8.1.10, 8.0.11, and 7.2.15, AOF loading ran ACL checks on replayed commands, and the release notes describe silent data loss when the default user is disabled (#3984). Database ACL bypasses through COPY, MOVE, and CLUSTER FLUSHSLOT were closed in 9.1.1 and 9.1.2.
Which commands should no application run?
@admin holds CONFIG, DEBUG, MONITOR, ACL, SHUTDOWN, and REPLICAOF. @dangerous adds FLUSHALL, KEYS, MIGRATE, RESTORE, CLIENT, and INFO. MONITOR streams every command with its arguments. The 9.1.3 valkey.conf marks rename-command as deprecated in favor of ACLs. Run ACL CAT dangerous after each minor upgrade, because new commands join categories.
How Valkey evaluates a command against these rules
A user has root permissions and optional selectors in parentheses. A command runs if any one permission set allows the command and every key, channel, and database it touches. Valkey 9.2, still a release candidate as of November 2026, adds named roles, checked the same way after the selectors.
ACL evaluation for one command in Valkey 9.1
Each permission set is checked in turn; the first full match runs the command
OR, not AND: selectors and roles only add rights. In the 9.2 release candidates a new role starts with alldbs, so giving it to a db=0 user grants every database unless the role has its own db= rule.
What permissions do replicas, Sentinel, and monitoring agents need?
Each needs a small, fixed command set. The ACL documentation lists the replication commands, and the 9.1.3 sentinel.conf carries a more current Sentinel rule than the ACL page.
# On every node (rule from the ACL docs)
user replica-user on >REPLACE_ME +@write ~* -@dangerous +ping +select +sync +psync +replconf +cluster|syncslots -flushall -flushdb -restore -restore-asking
# Also on every node, because any node can become a replica after a failover
primaryuser replica-user
primaryauth REPLACE_MEInterpretation: plain replication needs only PING, SYNC, PSYNC, and REPLCONF; the @write, SELECT, and CLUSTER SYNCSLOTS part serves atomic slot migration in 9.0 cluster mode. A replica always treats its primary as the root user, so replicas need no user for the primary.
# On every monitored node (rule from the 9.1.3 sentinel.conf)
user sentinel-user on >REPLACE_ME +subscribe +publish +failover +script|kill +ping +info +multi +slaveof +config +client +exec &__sentinel__:hello
# In sentinel.conf
sentinel auth-user mymaster sentinel-user
sentinel auth-pass mymaster REPLACE_MEInterpretation: since Sentinel 9.0 the user needs +failover, which the ACL page example omits. Sentinel holds all of CONFIG and CLIENT, so treat its password like an admin password.
# Monitoring: no keys, no CONFIG
user monitor on >REPLACE_ME resetchannels -@all +ping +info +client|list +slowlog|get +latency|latest +cluster|infoInterpretation: grant what your exporter calls, then read ACL LOG after the first scrape. Keep CONFIG GET away from monitoring: in 9.1.3 the sensitive flag only redacts CONFIG SET arguments in logs, so CONFIG GET primaryauth returns the replication password in plain text.
How do you enable TLS and rotate certificates without a restart?
TLS is a build option (make BUILD_TLS=yes, or module, which Sentinel does not support). The official Docker image is built with BUILD_TLS=yes; check distribution packages yourself. A TLS-only node:
port 0
tls-port 6379
tls-cert-file /etc/valkey/tls/server.crt
tls-key-file /etc/valkey/tls/server.key
tls-ca-cert-file /etc/valkey/tls/ca.crt
tls-auth-clients yes
tls-replication yes
tls-cluster yes
tls-protocols "TLSv1.2 TLSv1.3"
tls-auto-reload-interval 3600Interpretation: port 0 closes the plaintext listener. tls-auth-clients yes, the default, requires a client certificate; optional accepts one without requiring it. Valkey never uses the system CA store, so a CA file or directory is required. tls-replication covers replicas and Sentinel, and tls-cluster covers the cluster bus. TLSv1.2 and TLSv1.3 are already the defaults; stating them helps an audit.
tls-auto-reload-interval is new in 9.1 and defaults to 0 (off). Above zero, a background thread checks the files on that interval and swaps in new material without blocking the main thread. Pull request #3020 says it detects certificate changes by SHA-256 fingerprint, and key file and CA directory changes by inode and modification time. A reload through CONFIG SET still runs synchronously.
# Option A: atomic renames, then wait one interval
mv /etc/valkey/tls/server.key.new /etc/valkey/tls/server.key
mv /etc/valkey/tls/server.crt.new /etc/valkey/tls/server.crt
# Option B: versioned files switched in one CONFIG SET, then persisted
valkey-cli --tls --cacert /etc/valkey/tls/ca.crt --user admin --askpass \
CONFIG SET tls-cert-file /etc/valkey/tls/server-2026-11.crt \
tls-key-file /etc/valkey/tls/server-2026-11.key
valkey-cli --tls --cacert /etc/valkey/tls/ca.crt --user admin --askpass CONFIG REWRITEInterpretation: renames switch each file at once, so a check never reads a half-written file. Option B returns any error to you, and setting certificate and key in one command avoids a mismatched pair.
Using the client certificate as the user
tls-auth-clients-user logs a client in as the ACL user named in its certificate: CN since 9.0, or the first SAN URI that matches an enabled user since 9.1. If no user matches, the client stays the default user and ACL LOG records a tls-cert entry. With a nopass default user, that client has full access.
tls-auth-clients-user URI
# resetpass: this user can log in only with a certificate
user urn:valkey:user:orders on resetpass resetchannels ~orders:* db=0 -@all +@read +@write +@connection -@dangerousInterpretation: a client with URI:urn:valkey:user:orders in its SAN is authenticated during the handshake. Run 9.1.2 or later, which compares the whole CN so an embedded NUL byte cannot impersonate a shorter username (#4577).
CVE-2026-56684, a use-after-free in TLS connection handling reachable through CLIENT KILL, was fixed in 9.1.1, 9.0.5, 8.1.9, 8.0.10, and 7.2.14.
What happens when a certificate expires on Valkey 9.1.x?
Valkey 9.1.x loads it anyway. The TLS documentation says loading fails "immediately" for a certificate outside its validity period. That is pull request #2999, which pull request #3572 reverted from 9.1 in 9.1.0-rc2 "as it's a breaking change", deferring it to the next major version. The 9.1.3 tls.c has no validity check.
So an expired certificate does not stop startup, CONFIG SET, or an automatic reload. The failure moves to the peers: clients that verify the server certificate refuse the handshake, and with tls-replication and tls-cluster, replicas and cluster nodes verify each other too, so links drop as connections are re-established. The 9.2.0-rc2 source does contain the check and logs Server TLS certificate is invalid. Aborting TLS configuration., so expect startup failures there.
valkey-cli --tls --cacert /etc/valkey/tls/ca.crt --user monitor --askpass INFO tlsInterpretation: the section, added in 9.1 (#2913), is not part of plain INFO. It reports a serial and an expires_in_seconds value for the server, client, and CA certificates, such as tls_server_cert_expires_in_seconds. Each counter stops at 0, so a serial with 0 seconds left means expired. Alert at 14 days (1,209,600 seconds) on all three.
How do you authenticate Valkey users against LDAP?
With valkey-ldap, an official Valkey module written in Rust, compatible with Valkey 7.2 and later. As of November 2026 the latest release is 1.1.1 (29 June 2026). The repository publishes RPM and DEB build artifacts, and the valkey-bundle 9.1 image ships 1.1.1; its entrypoint loads every module in /usr/lib/valkey, used or not.
The module only checks passwords. Each LDAP user must also exist as a Valkey ACL user that holds its permissions, created without a password so LDAP is the only way in. MODULE LOAD is blocked by default, so load it from the config file:
loadmodule /usr/lib/valkey/libvalkey_ldap.so
ldap.servers "ldaps://ldap1.example.internal:636 ldaps://ldap2.example.internal:636"
ldap.auth_mode search+bind
ldap.search_bind_dn "cn=valkey-svc,ou=service,dc=example,dc=internal"
ldap.search_bind_passwd REPLACE_ME
ldap.search_base "ou=people,dc=example,dc=internal"
ldap.tls_ca_cert_path /etc/valkey/tls/ldap-ca.crtInterpretation: search+bind finds the user's DN by the uid attribute (the default), then binds as that DN with the AUTH password. Use ldaps:// or ldap.use_starttls yes, or passwords reach the directory unencrypted. The docs' prose says ldap.search_base_dn, but the option table and README say ldap.search_base.
valkey-cli ACL SETUSER alice on resetpass resetchannels -@all +@read +info '~*'
valkey-cli INFO ldap_status # per server: healthy with ping_time_ms, or unhealthy with errorInterpretation: alice has no local password, so her AUTH succeeds only if the directory accepts it. Keep one local admin with a vaulted password for when the directory is unreachable.
Use LDAP for people, not services. According to the project README, every AUTH goes to the module first, so a service account with a local password costs an LDAP lookup and a failure log line; an ldap.exclude_users option is on the main branch but in no release yet. The default pool is 2 connections per server. If you run 1.0.0, upgrade: 1.0.1 fixed CONFIG REWRITE writing an obfuscated ldap.search_bind_passwd.
Can you turn Lua off in Valkey 9.1?
Not at runtime. 9.1.0-rc1 moved the Lua engine into a module (#2858), and 9.1.0-rc2 made static linking the default (#3392). The 9.1 announcement speaks of "the option to disable Lua entirely", but there is no valkey.conf directive, and server.c loads the static engine at startup. make BUILD_LUA=no builds without the engine; BUILD_LUA=module builds libvalkeylua.so, loaded only through loadmodule. The official Docker image uses the default, so it has Lua. Since 9.1.2, Sentinel does not load Lua.
Of the 24 CVE and GHSA identifiers in Valkey release notes since October 2024, nine involve Lua:
| Advisory | Issue (release note wording) | Fixed in |
|---|---|---|
| CVE-2024-31449 | Lua library commands may lead to stack overflow and potential RCE | 8.0.1, 7.2.7 |
| CVE-2024-46981 | Lua script commands may lead to remote code execution | 8.0.2, 7.2.8 |
| CVE-2025-49844, -46817, -46818, -46819 | RCE, integer overflow, execution as another user, out-of-bound read | 8.1.4, 8.0.6, 7.2.11 |
| CVE-2025-67733 | RESP protocol injection via Lua error_reply | 9.0.3, 8.1.6, 8.0.7, 7.2.12 |
| CVE-2026-23631 | Use-after-free when full sync occurs during a yielding Lua or function execution | 9.1.0, 9.0.4, 8.1.7, 8.0.9, 7.2.13 |
| GHSA-fq2f-crmw-q97r | Unauthenticated use-after-free of the Lua interpreter state | 9.1.2 |
Inventory scripts first. Job queues such as BullMQ, lock libraries, and rate limiters often call EVALSHA under the hood:
valkey-cli INFO commandstats | grep -E 'cmdstat_(eval|evalsha|eval_ro|evalsha_ro|fcall|fcall_ro|script.load|function.load):'
valkey-cli FUNCTION LIST
valkey-cli INFO scriptingenginesInterpretation: command stats count since the last restart or CONFIG RESETSTAT, so sample across a full business cycle. Any non-zero calls, or any library in FUNCTION LIST, means a client depends on Lua. INFO scriptingengines shows engines_count, which is 0 on a Lua-less build.
git clone --depth 1 --branch 9.1.3 https://github.com/valkey-io/valkey.git
cd valkey && make BUILD_TLS=yes BUILD_LUA=noInterpretation: without an engine, EVAL and FUNCTION LOAD return an error that the scripting engine cannot be found. Build every node in a shard the same way, because a promoted Lua-less replica breaks every script. If you keep Lua, remove @scripting from users that do not need it.
In what order should you roll out these changes?
Each step can lock out a client the previous step missed, so this order keeps the default user working until every service has moved.
Valkey 9.1 hardening runbook
Eight steps; step 6 can lock out any client you did not inventory
-
Patch to the current release
9.1.3 carries the ACL, AOF, and TLS fixes above. Read valkey_version, not redis_version.
valkey-cli INFO server | grep valkey_version -
Find who connects as the default user
The addr and lib-name fields name the host and client library.
valkey-cli CLIENT LIST USER default -
Write users to an ACL file
Services plus replica, Sentinel, monitor, and admin users. Switching to aclfile needs one restart.
valkey-cli ACL LOAD -
Prove each user with DRYRUN
Test every command the service sends, including what its library sends on connect.
valkey-cli ACL DRYRUN orders \ HSET orders:1 status paid -
Move services one at a time
Deploy new credentials per service and read ACL LOG after each.
valkey-cli ACL LOG 10 -
Turn off the default user
Only when CLIENT LIST USER default is empty. Save the change.
valkey-cli ACL SETUSER default off resetpass valkey-cli ACL SAVE -
Add TLS beside TCP, then close TCP
Both ports change at runtime. Move clients to the TLS port first.
valkey-cli CONFIG SET tls-port 6380 valkey-cli CONFIG SET port 0 -
Alert on certificates and denials
Watch expiry seconds and the acl_access_denied counters.
valkey-cli INFO tls valkey-cli INFO stats | grep acl_access_denied
Rollback point: until step 6, unmigrated clients still work as the default user. After it, an admin session can turn the default user back on with a password while you fix the missed client.
How do you debug NOPERM errors after hardening?
In the 9.1.3 source, a client receives one of four NOPERM messages, and only the first names anything: User orders has no permissions to run the 'flushall' command, No permissions to access a key, No permissions to access a channel, or No permissions to access database.
ACL LOG fills the gap. Each entry has a reason (command, key, channel, database, auth, or tls-cert), a context (toplevel, multi, lua, script, or module), the denied object, the username, and the client's details. Repeats within 60 seconds fold into one entry with a count. The log keeps 128 entries by default, and ACL LOG shows the 10 newest.
From a vague NOPERM to the exact key
Illustrative output, trimmed to the first seven fields of one ACL LOG entry
Read the object field: the client only saw "No permissions to access a key". The log names the key and user, and count shows 37 denials folded into one entry.
valkey-cli INFO stats | grep acl_access_denied # auth, cmd, key, channel, tls_cert, db
valkey-cli ACL DRYRUN orders GET billing:8812 # full denial text, no side effects
valkey-cli ACL DRYRUN orders SELECT 3 # database access check
valkey-cli ACL LOG RESET # clear after the fix, then watch againInterpretation: ACL DRYRUN returns OK or the full message. In 9.1.3 it checks key commands against the database your own session has selected, so SELECT the service's database first, or test SELECT directly.
The usual causes are few. Client libraries send HELLO, CLIENT SETINFO, SELECT, or INFO on connect, and -@dangerous removes INFO. Pub/Sub clients need channel patterns, because acl-pubsub-default is resetchannels. Scripts run with the caller's permissions, so a denial inside one shows context lua.
Frequently asked questions
Does Valkey 9.1 reject expired TLS certificates?
No. Valkey 9.1.x loads expired or not-yet-valid certificates at startup, on CONFIG SET, and on automatic reload, although the TLS documentation says loading fails. The check was reverted from 9.1 in pull request #3572. Clients and peers that verify the certificate still refuse the handshake, so alert on the expiry fields in INFO tls. The 9.2 release candidate source rejects them at load.
How do I disable Lua scripting in Valkey 9.1?
Build Valkey with make BUILD_LUA=no. Valkey 9.1 links Lua statically by default and loads it at startup, and no configuration directive turns it off. Before switching builds, check INFO commandstats for EVAL, EVALSHA, and FCALL calls and run FUNCTION LIST, because job queues and lock libraries often use scripts. Build every node in a shard the same way.
How do I restrict a Valkey user to one database?
On Valkey 9.1 or later, add a db= rule, for example ACL SETUSER orders db=0. A later db= rule replaces the list rather than adding to it. New connections still start in database 0, so set the database in the client's connection options. Run 9.1.2 or later, which closed MOVE and COPY bypasses of database rules.
Why does Valkey return NOPERM No permissions to access a key?
The user's key patterns do not cover a key the command touches, and the client message leaves the key name out. Run ACL LOG as an admin to see the key, the username, and the client address, or ACL DRYRUN with the same user and command to get the full message. Then widen the pattern or fix the key name.
Key takeaways
- Check the running server, not the docs: without a config file it binds every interface, and the official Docker image compiles protected mode off.
- Disable the default user and give each application, replica, Sentinel, and monitoring agent a minimal user on 9.1.3, with
db=for database scoping. - Enable TLS for clients, replication, and the cluster bus, set
tls-auto-reload-interval, and alert onINFO tls, because 9.1.x loads expired certificates. - Use valkey-ldap 1.1.1 for people, over LDAPS with a local break-glass admin.
- Remove Lua with
make BUILD_LUA=noonly after an inventory, and debug denials withACL LOGandACL DRYRUN.
Harden your Valkey deployment with JusDB
JusDB's Valkey support team can review your configuration, ACL users, and TLS setup against this checklist, inventory the clients and scripts that depend on the default user or Lua, and plan the rollout order with you.
Bring your Valkey versions, topology (standalone, Sentinel, or cluster), current ACL LIST output with passwords removed, and how certificates are issued today. Your team approves every production change.