Skip to content

A Deleted User Is an Authentication Failure: 401, Not 500

Adityo Guni Waluyo

A cryptographically valid JWT can still name a user who no longer exists. Mapping sql.ErrNoRows to 401 lets the frontend purge the stale session.

TL;DR

A deleted user's JWT kept hitting the API, and since the middleware treated missing rows as a generic DB error, users got stuck in a 500 banner loop instead of being logged out. The fix uses errors.Is(err, sql.ErrNoRows) to return 401, letting the frontend purge the stale session. Tests now pin both paths.

During a closed QA session for the KotaPortal platform, the request logs revealed a persistent and disruptive anomaly. An old JSON Web Token continued to hit the API long after the associated user account had been permanently deleted from the system. Instead of triggering a clean and expected login redirect, the user interface screen filled with repeated 500 Internal Server Error banners. The end user remained completely trapped in this banner loop, unable to proceed, until the token expired naturally.

The initial debugging assumption was that a cryptographically valid JWT signature inherently equated to a valid, active session. This premise is fundamentally incorrect. A cryptographic signature only proves who issued the token at the precise time of issuance. It says absolutely nothing about whether the named principal still exists or remains active in the underlying database.

The ResolveBidang middleware previously treated every single database lookup error as a generic, catch-all failure. However, when a lookup finds no matching row, the Scan call returns a sentinel from the standard library [3]. The middleware logic was updated to check for this specific condition explicitly using errors.Is from Go 1.13 [4].

On the repository side, the bidang lookup by user_id goes through QueryRow and Scan. When the row no longer exists, Scan returns the standard-library sentinel:

var ErrNoRows = errors.New("sql: no rows in result set")

That sentinel is the classification line between authentication failure and infrastructure failure.

if errors.Is(err, sql.ErrNoRows) {
    // Validly-signed token naming a deleted principal is an
    // authentication failure: 401 lets the frontend purge the
    // stale session (shouldClearSession) instead of a 500 banner.
    slog.Warn("token user no longer exists", "user_id", userID)
    response.Error(w, http.StatusUnauthorized, "UNAUTHORIZED",
        "sesi tidak valid — pengguna tidak ditemukan, silakan login ulang")
    return
}

Any other lookup error still returns 500 INTERNAL_ERROR. This ensures real infrastructure failures are not disguised as routine authentication problems.

The frontend session purge policy, governed by the shouldClearSession flag, is designed to trigger exclusively upon receiving a 401 response. When the backend returns a 500 status code instead, the frontend client assumes a transient network problem. Consequently, it retains the stored token, causing every following request to fail with the exact same repetitive pattern.

To prevent regressions, the integration test that previously froze the old behavior (characterizing the 500 response) was updated. It now explicitly demands a 401 UNAUTHORIZED response, including the specific error message. A new unit middleware test feeds sql.ErrNoRows directly and asserts a 401 status. Meanwhile, the existing source-error test still asserts 500 INTERNAL_ERROR, ensuring the fail-closed behavior remains completely unchanged.

This alignment is not arbitrary. RFC 9110 section 15.5.2 defines 401 as the request not being applied because it lacks valid authentication credentials for the target resource [1]. Credentials naming a principal that no longer exists are operationally invalid. Furthermore, RFC 6750 section 3.1 lists invalid_token as expired, revoked, malformed, or invalid for other reasons, stating the resource SHOULD respond with 401 [2]. Administrative account deletion is exactly such a reason.

Precise error classification is not merely a cosmetic preference; it is a functional feature. HTTP status codes act as a strict contract between the backend and the frontend. Returning a 500 for a clear authentication problem actively breaks the self-recovery mechanism the user interface was explicitly designed around.

Sources

[1] RFC 9110, section 15.5.2 (401 Unauthorized), accessed 13 October 2026.
[2] RFC 6750, section 3.1 (invalid_token), accessed 13 October 2026.
[3] Go database/sql, ErrNoRows, accessed 13 October 2026.
[4] Working with Errors in Go 1.13, accessed 13 October 2026.

Related articles