Back to Blog
SecurityOctober 9, 2026

Preventing Self-Lockouts: Why We Hardcode Admin Protections at the API Edge

A technical look at administrative self-lockouts, why hiding buttons in the UI is never enough, and how Mezite enforces self-modification guards on every API surface that can change a user.

When you build an infrastructure access platform, the blast radius of an administrative mistake is enormous. If an administrator revokes their own access, they do not just lose the control plane UI. They lose access to the servers they would need to fix the control plane.

In a self-hosted system like Mezite, recovering from a self-lockout means reaching around the platform entirely: shelling into the host and editing SQLite or PostgreSQL rows by hand to get back in. That is an operational nightmare, and exactly the kind of break-glass procedure that tends to go wrong under pressure.

So we guard against it. But where those guards live decides whether they actually work or are merely cosmetic. Here is how we defend against self-lockouts in the Mezite API.

The Illusion of Frontend Security

The naive approach is to hide destructive actions in the user interface. If the logged-in administrator is viewing their own profile, the frontend simply doesn’t render the “Delete”, “Lock”, or “Edit roles” buttons.

That is a fine piece of UX, and a terrible security control.

Frontend state is easy to manipulate and APIs are easy to call directly. A hijacked session, a rogue script, or an accidental curl against the wrong username bypasses the UI entirely. If the backend trusts the request because the session carries an admin role, the system fails open and the administrator removes themselves.

Controls that exist only in the browser are suggestions. Enforcement has to happen at the API boundary.

Explicit Guards in the Go Handlers

Every Mezite handler that alters a user’s account, roles, or access compares the target of the operation against the session making the request, before it touches the store:

server/web/users_admin.go (simplified)go
func (api *WebAPI) handleLockUser(w http.ResponseWriter, r *http.Request, username string) {
ctx := r.Context()
sess := sessionFromContext(ctx)
if sess == nil {
	writeError(w, http.StatusUnauthorized, "unauthorized")
	return
}

// Prevent self-lockout — same guard the generic locks API applies.
if sess.Username == username {
	writeError(w, http.StatusBadRequest, "cannot lock your own account")
	return
}

// Proceed with locking...
}

The same check sits at the top of every handler in the user admin surface:

Endpoint Refusal
Update user roles cannot modify your own account roles
Delete user cannot delete your own account
Lock user cannot lock your own account
Unlock user cannot unlock your own account
Reset password use the change-password endpoint for your own account

It is deliberately boring, explicit code. There is no middleware magic that a new route could forget to opt into; the guard is visible in the handler that needs it, and each one has a test asserting the refusal.

Locking a Role You Hold

Accounts are not the only way to lock yourself out. Mezite’s generic locks API can lock a role, which immediately tears down every session holding it. An administrator who locks the admin role has just locked themselves out as surely as if they had locked their own username.

So the locks API checks both:

server/web/locks.go (simplified)go
lockedBy := sess.Username
if req.TargetType == "user" && req.TargetName == lockedBy {
writeError(w, http.StatusBadRequest, "cannot lock your own account")
return
}
// Also refuse a role lock when the caller holds that role — otherwise an
// admin locking the "admin" role traps themselves out of the system.
if req.TargetType == "role" {
for _, r := range sess.Roles {
	if r == req.TargetName {
		writeError(w, http.StatusBadRequest, "cannot lock a role you currently hold")
		return
	}
}
}

This one came out of an internal red-team exercise. It is the kind of lockout that is easy to miss when you only think about guards in terms of usernames.

Every Surface, Not Just the Web API

The web API is not the only way to change a user. The gRPC auth service has its own UpdateUser call that can change a user’s status, and setting it to locked or disabled creates the same identity lock as the web handler. It carries the same guard, checked before any store write so a rejected request never half-applies a role or trait change:

server/auth/grpc_handlers.go (simplified)go
if req.Status == StatusLocked || req.Status == StatusDisabled {
if caller := AuthenticatedUserFromContext(ctx); caller != "" && caller == req.Username {
	return nil, status.Error(codes.InvalidArgument, "cannot lock your own account")
}
}

The rule is simple: any API surface that can change who a user is or what they can reach must know who is asking, and must refuse when the answer is “themselves”.

Defense in Depth: The Password Reset Case

The most interesting guard is not on deletion but on password resets.

POST /v1/webapi/users/{username}/reset-password lets an administrator set a new password for another user without knowing the old one. That is what makes it useful for recovering lost accounts. It is also what makes it dangerous.

server/web/users_admin.go (simplified)go
func (api *WebAPI) handleResetPassword(w http.ResponseWriter, r *http.Request, username string) {
// Self-mod guard: admins must use the change-password endpoint for their
// own account so the old password is re-verified (defence in depth
// against a hijacked admin session being used to lock out the real admin).
sess := sessionFromContext(r.Context())
if sess == nil {
	writeError(w, http.StatusUnauthorized, "unauthorized")
	return
}
if sess.Username == username {
	writeError(w, http.StatusBadRequest, "use the change-password endpoint for your own account")
	return
}

// Proceed with forced reset...
}

Why refuse an administrator resetting their own password? Session hijacking.

Suppose an attacker steals an active administrator’s session cookie, perhaps through an XSS bug that slipped past our Content Security Policy. If that session could hit reset-password on its own account, the attacker could set a new password, sign the real administrator out of their own account, and keep it.

Forcing the administrator through the self-service change-password endpoint (/v1/webapi/password) adds one constraint the attacker doesn’t have: it requires the current password. A stolen cookie is no longer enough to take the account permanently.

Conclusion

Do not assume that a user with elevated privileges will not harm themselves. Automation mistakes, hijacked sessions, and plain fat-fingering all end in the same place: an administrator cut off from the system they are responsible for.

Checking sess.Username == username (and “do you hold this role?”) on every API surface that mutates access is a small amount of code. It prevents some of the most painful operational failures a platform can suffer.


MT

Mezite Team

Engineering