Subject: Re: Periodic authorization expiration checks using GoAway message

From: Zsolt Parragi <[email-masked]>
Date: Tue, 20 Jan 2026 09:28:50 +0000

Hello!

I noticed a few things in the patch, please consider the following:

postgres.c:1076: elsewhere password_valid_until_timestamp is set to 0
when NULL, won't that result in unintended disconnection for users?

postgres.c:99: it only checks the expiration in exec_simple_query,
shouldn't it also be part of other methods (like
exec_execute_message)?

postgres.c:179: isn't the sys_cache_register_callback variable name a
bit too generic, shouldn't it have a more specific name related to
password expiration / authentication?

postgres.c:1082: the errhint text should have a period at the end.

postgres.c:4185: The comment for CheckPasswordExpiration says that the
function terminates the connection with FATAL, but the termination is
actually at the call site at line 1077. Maybe it would be better to
move that if/error inside the function, as the comment explains?

---

From: Ajit Awekar <[email-masked]>
Date: Tue, 20 Jan 2026 18:01:21 +0530

Hi Zsolt,

Thanks a lot for your review comments.

>when NULL, won't that result in unintended disconnection for users?
Done. I have modified the condition check so as it will not impact users
having rolvaliduntil to NULL.

>shouldn't it also be part of other methods (like
I missed it.  In the attached patch validation is now performed across all
primary query execution entry points to cover both simple and Extended
query protocols.

>password expiration / authentication?
I agree. I have renamed the variable  to a more appropriate name
password_auth_cache_callback_registered.

>  postgres.c:1082: the errhint text should have a period at the end.
Done.

>move that if/error inside the function, as the comment explains?
Done.

I have attached an updated patch. Request a review.

Thanks & Best Regards,
Ajit

> move that if/error inside the function, as the comment explains?
diff --git a/src/backend/libpq/crypt.c b/src/backend/libpq/crypt.c
index 4c1052b3d42..eafc4309748 100644
--- a/src/backend/libpq/crypt.c
+++ b/src/backend/libpq/crypt.c
@@ -20,6 +20,7 @@
 #include "common/scram-common.h"
 #include "libpq/crypt.h"
 #include "libpq/scram.h"
+#include "miscadmin.h"
 #include "utils/builtins.h"
 #include "utils/syscache.h"
 #include "utils/timestamp.h"
@@ -66,7 +67,19 @@ get_role_password(const char *role, const char **logdetail)
 	datum = SysCacheGetAttr(AUTHNAME, roleTup,
 							Anum_pg_authid_rolvaliduntil, &isnull);
 	if (!isnull)
+	{
 		vuntil = DatumGetTimestampTz(datum);
+		/*
+		 * Cache the password expiration timestamp from pg_authid.rolvaliduntil
+		 * during initial authentication so it can be checked throughout the
+		 * lifetime of the connection. By changing this value from -1 to >= 0
+		 * we signal that password authentication was used.
+		 */
+		password_valid_until_timestamp = vuntil;
+	}
+	/* No expiration limit set */
+	else
+		password_valid_until_timestamp = 0;
 
 	ReleaseSysCache(roleTup);
 
diff --git a/src/backend/tcop/postgres.c b/src/backend/tcop/postgres.c
index e54bf1e760f..6a62cfc50bd 100644
--- a/src/backend/tcop/postgres.c
+++ b/src/backend/tcop/postgres.c
@@ -34,6 +34,7 @@
 #include "access/parallel.h"
 #include "access/printtup.h"
 #include "access/xact.h"
+#include "catalog/pg_authid.h"
 #include "catalog/pg_type.h"
 #include "commands/async.h"
 #include "commands/event_trigger.h"
@@ -74,10 +75,12 @@
 #include "tcop/utility.h"
 #include "utils/guc_hooks.h"
 #include "utils/injection_point.h"
+#include "utils/inval.h"
 #include "utils/lsyscache.h"
 #include "utils/memutils.h"
 #include "utils/ps_status.h"
 #include "utils/snapmgr.h"
+#include "utils/syscache.h"
 #include "utils/timeout.h"
 #include "utils/timestamp.h"
 #include "utils/varlena.h"
@@ -105,6 +108,13 @@ int			client_connection_check_interval = 0;
 /* flags for non-system relation kinds to restrict use */
 int			restrict_nonsystem_relation_kind;
 
+/*
+ * Flag set by syscache listener to indicate if the user's password validity
+ * (rolvaliduntil) needs to be checked for expiration before the next
+ * command execution.
+ */
+static bool	AuthCheckNeeded = false;
+
 /* ----------------
  *		private typedefs etc
  * ----------------
@@ -163,6 +173,13 @@ static volatile sig_atomic_t RecoveryConflictPendingReasons[NUM_PROCSIGNALS];
 static MemoryContext row_description_context = NULL;
 static StringInfoData row_description_buf;
 
+/*
+ * Tracks whether the SysCache callback for AUTHOID has been registered.
+ * This ensures CacheRegisterSyscacheCallback is called exactly once during
+ * backend initialization, preventing redundant registrations in the main loop.
+ */
+static bool password_auth_cache_callback_registered = false;
+
 /* ----------------------------------------------------------------
  *		decls for routines only used in this file
  * ----------------------------------------------------------------
@@ -186,6 +203,8 @@ static void drop_unnamed_stmt(void);
 static void log_disconnections(int code, Datum arg);
 static void enable_statement_timeout(void);
 static void disable_statement_timeout(void);
+static void AuthCacheInvalidated(Datum arg, int cacheid, uint32 hashvalue);
+static void enforce_password_expiration(void);

 /* ----------------------------------------------------------------
@@ -1050,6 +1069,11 @@ exec_simple_query(const char *query_string)
 	 */
 	start_xact_command();
 
+	/*
+	 * Verify that the user's password has not expired.
+	 */
+	enforce_password_expiration();
+
 	/*
 	 * Zap any pre-existing unnamed statement.  (While not strictly necessary,
 	 * it seems best to define simple-Query mode as if it used the unnamed
@@ -1432,6 +1456,11 @@ exec_parse_message(const char *query_string,	/* string to execute */
 	 */
 	start_xact_command();
 
+	/*
+	 * Verify that the user's password has not expired.
+	 */
+	enforce_password_expiration();
+
 	/*
 	 * Switch to appropriate context for constructing parsetrees.
 	 *
@@ -1708,6 +1737,11 @@ exec_bind_message(StringInfo input_message)
 	 */
 	start_xact_command();
 
+	/*
+	 * Verify that the user's password has not expired.
+	 */
+	enforce_password_expiration();
+
 	/* Switch back to message context */
 	MemoryContextSwitchTo(MessageContext);
 
@@ -2221,6 +2255,11 @@ exec_execute_message(const char *portal_name, long max_rows)
 	 */
 	start_xact_command();
 
+	/*
+	 * Verify that the user's password has not expired.
+	 */
+	enforce_password_expiration();
+
 	/*
 	 * If we re-issue an Execute protocol request against an existing portal,
 	 * then we are only fetching more rows rather than completely re-executing
@@ -2654,6 +2693,11 @@ exec_describe_statement_message(const char *stmt_name)
 	 */
 	start_xact_command();
 
+	/*
+	 * Verify that the user's password has not expired.
+	 */
+	enforce_password_expiration();
+
 	/* Switch back to message context */
 	MemoryContextSwitchTo(MessageContext);
 
@@ -2747,6 +2791,11 @@ exec_describe_portal_message(const char *portal_name)
 	 */
 	start_xact_command();
 
+	/*
+	 * Verify that the user's password has not expired.
+	 */
+	enforce_password_expiration();
+
 	/* Switch back to message context */
 	MemoryContextSwitchTo(MessageContext);
 
@@ -4518,6 +4567,20 @@ PostgresMain(const char *dbname, const char *username)
 	if (!ignore_till_sync)
 		send_ready_for_query = true;	/* initially, or after error */
 
+
+	/*
+	 * Register a SysCache listener for pg_authid changes (specifically for
+	 * rolvaliduntil). This provides an event-driven mechanism to enforce
+	 * password/authorization expiration immediately upon change, rather than
+	 * relying on polling. The callback sets a flag (AuthCheckNeeded) which
+	 * is checked before executing each simple query.
+	 */
+	if (!password_auth_cache_callback_registered)
+	{
+		CacheRegisterSyscacheCallback(AUTHOID, AuthCacheInvalidated, (Datum) 0);
+		password_auth_cache_callback_registered = true;
+	}
+
 	/*
 	 * Non-error queries loop here.
 	 */
@@ -5237,3 +5300,92 @@ disable_statement_timeout(void)
 	if (get_timeout_active(STATEMENT_TIMEOUT))
 		disable_timeout(STATEMENT_TIMEOUT, false);
 }
+
+/*
+ * CheckPasswordExpiration
+ * Refreshes the cached password expiration timestamp from the system cache.
+ * This function looks up the current user's entry in pg_authid and updates
+ * 'password_valid_until_timestamp' with the current value of 'rolvaliduntil'.
+ * It is called by enforce_password_expiration() when the 'AuthCheckNeeded'
+ * flag is set, typically due to a syscache invalidation (AuthCacheInvalidated).
+ */
+static void
+CheckPasswordExpiration(void)
+{
+	HeapTuple	tuple;
+
+	/*
+	 * Look up the current user's entry in pg_authid. We must do this, even
+	 * if only AuthCheckNeeded is set, because GetUserId() might return a
+	 * different user ID than the one that triggered the invalidation (though
+	 * that's unlikely for AUTHOID).
+	 */
+
+	tuple = SearchSysCache1(AUTHOID, ObjectIdGetDatum(GetUserId()));
+
+	if (HeapTupleIsValid(tuple))
+	{
+		Datum 		rolvaliduntil_datum;
+		bool		validUntil_null;
+
+		/* Get the expiration time column */
+		rolvaliduntil_datum = SysCacheGetAttr(AUTHNAME, tuple,
+											  Anum_pg_authid_rolvaliduntil,
+											  &validUntil_null);
+
+		if (!validUntil_null)
+			password_valid_until_timestamp = DatumGetTimestampTz(rolvaliduntil_datum);
+		else
+			password_valid_until_timestamp = 0;
+
+		ReleaseSysCache(tuple);
+	}
+	/* Reset the flag after performing the check */
+	AuthCheckNeeded = false;
+}
+
+/*
+ * enforce_password_expiration
+ *
+ * Check if the user's password has expired and terminate the connection
+ * if necessary. This encapsulates the state checks and the FATAL report.
+ * CheckPasswordExpiration must only be called when the system is out of
+ * recovery and inside a valid transaction.
+ */
+static void
+enforce_password_expiration(void)
+{
+
+	if (!RecoveryInProgress() && IsTransactionState() &&
+		password_valid_until_timestamp > 0)
+	{
+		if (AuthCheckNeeded)
+			CheckPasswordExpiration();
+
+		if (password_valid_until_timestamp < GetCurrentTransactionStartTimestamp())
+			ereport(FATAL,
+					(errcode(ERRCODE_INVALID_AUTHORIZATION_SPECIFICATION),
+					 errmsg("Connection expired due to internal password policy enforcement"),
+					 errdetail("User's password expired at %s.",
+							   timestamptz_to_str(password_valid_until_timestamp)),
+					 errhint("Reconnect with a renewed password.")));
+	}
+}
+
+/*
+ * AuthCacheInvalidated
+ * Syscache callback function registered for the AUTHOID cache (pg_authid).
+ *
+ * This function is executed whenever a tuple in pg_authid is updated, inserted,
+ * or deleted. Its primary purpose is to catch changes to the currently
+ * connected user's 'rolvaliduntil' field.
+ *
+ * It sets the static flag AuthCheckNeeded to true, signaling that the user's
+ * password expiration status must be checked.
+ */
+static void
+AuthCacheInvalidated(Datum arg, int cacheid, uint32 hashvalue)
+{
+	/* This callback is executed when an entry in pg_authid changes */
+	AuthCheckNeeded = true;
+}
diff --git a/src/backend/utils/init/globals.c b/src/backend/utils/init/globals.c
index 36ad708b360..069138ec1d5 100644
--- a/src/backend/utils/init/globals.c
+++ b/src/backend/utils/init/globals.c
@@ -165,3 +165,10 @@ int			notify_buffers = 16;
 int			serializable_buffers = 32;
 int			subtransaction_buffers = 0;
 int			transaction_buffers = 0;
+
+/*
+ * Cached value of the current user's password expiration time (pg_authid.rolvaliduntil).
+ * This value is updated via CheckPasswordExpiration() when the AuthCheckNeeded
+ * flag is set by a syscache invalidation callback.
+ */
+TimestampTz	password_valid_until_timestamp = -1;
diff --git a/src/include/miscadmin.h b/src/include/miscadmin.h
index db559b39c4d..146c3769b2f 100644
--- a/src/include/miscadmin.h
+++ b/src/include/miscadmin.h
@@ -287,7 +287,7 @@ extern PGDLLIMPORT double VacuumCostDelay;
 
 extern PGDLLIMPORT int VacuumCostBalance;
 extern PGDLLIMPORT bool VacuumCostActive;
-
+extern PGDLLIMPORT TimestampTz password_valid_until_timestamp;
 
 /* in utils/misc/stack_depth.c */

---

From: Zsolt Parragi <[email-masked]>
Date: Thu, 22 Jan 2026 07:40:38 +0000

Hello!

> having rolvaliduntil to NULL.

Thanks! After one more look at this, shouldn't we refresh the cache if
AuthCheckNeeded, regardless of the valid until timestamp value? (for
example by moving the >0 condition to the inner if)

Consider the following scenario:

1. user logs in, without a valid until date set
2. valid until is set to something
3. existing session started in 1 will never be terminated

postgres.c:5368 - I missed this in my previos review, the error
message should start with a lowercase character.

> This patch introduces a mechanism to address the security issue of stale,
> authorized connections persisting beyond their validity period. .

postgres.c:5326 - if the user was dropped, but the connection is still
active, the patch silently ignores it. That matches the current
behavior of postgres, but is that the expected behavior in the context
of this patch?