Articles / Security

  • security
  • error-handling
  • go
  • authentication
  • authorisation
  • logging

When the Error Path Becomes the Security Boundary

The successful path is usually where the design is easiest to explain. A request arrives, authentication succeeds, the database answers, and the response goes back. The interesting security decisions often happen when one of those steps fails.

An error is a decision point, not just a message. It can decide whether access is denied, whether a transaction is rolled back, whether a secret is logged, whether a connection is reused, or whether the caller gets to try again.

Errors change what the caller is allowed to learn

Suppose an account lookup returns one of three results: the account exists and the password is wrong, the account does not exist, or the database is unavailable. Returning three visibly different responses gives the caller three different facts.

That may be harmless for an internal admin tool. It is much less harmless on a public login endpoint. An attacker can turn a small difference in status code, response text, timing, or rate limiting into an account-enumeration oracle.

func login(w http.ResponseWriter, r *http.Request) {
    account, err := findAccount(r.Context(), r.FormValue("email"))
    if err != nil {
        if errors.Is(err, sql.ErrNoRows) {
            http.Error(w, "invalid credentials", http.StatusUnauthorized)
            return
        }
        http.Error(w, "database failure", http.StatusInternalServerError)
        return
    }

    if err := checkPassword(account.PasswordHash, r.FormValue("password")); err != nil {
        http.Error(w, "invalid credentials", http.StatusUnauthorized)
        return
    }

    issueSession(w, account.ID)
}

This is ordinary code, and it leaks whether an email address exists. The database error may also reveal operational details that are useful to an attacker. The fix is not to hide every error forever. It is to choose deliberately which distinctions cross each boundary.

func login(w http.ResponseWriter, r *http.Request) {
    account, err := findAccount(r.Context(), r.FormValue("email"))
    if err != nil && !errors.Is(err, sql.ErrNoRows) {
        slog.ErrorContext(r.Context(), "account lookup failed", "err", err)
    }

    passwordOK := false
    if err == nil {
        passwordOK = checkPassword(account.PasswordHash, r.FormValue("password")) == nil
    } else {
        // Perform equivalent work where practical, so absence is not obvious.
        passwordOK = checkPassword(dummyPasswordHash, r.FormValue("password")) == nil
    }

    if err != nil || !passwordOK {
        http.Error(w, "invalid credentials", http.StatusUnauthorized)
        return
    }

    issueSession(w, account.ID)
}

This is still only a sketch. Password verification cost, rate limiting, account locking, and incident logging all need their own design. The important boundary is clear: detailed causes stay in controlled logs, while the public response exposes only the decision the caller needs.

Fail closed means deciding what failure means

Security checks are often written as if their dependencies cannot fail. An authorisation service returns a yes or no. A policy file loads. A certificate revocation check completes. Then the network goes away and the code has to invent a third answer.

Unknown is not the same as allowed. If a permission check cannot reach its policy source, treating the error as approval turns an availability problem into an access-control bypass.

func canDelete(ctx context.Context, userID, documentID string) (bool, error) {
    allowed, err := policyClient.Check(ctx, userID, "delete", documentID)
    if err != nil {
        return false, fmt.Errorf("check delete permission: %w", err)
    }
    return allowed, nil
}

func deleteDocument(w http.ResponseWriter, r *http.Request) {
    allowed, err := canDelete(r.Context(), userID(r), documentID(r))
    if err != nil {
        slog.ErrorContext(r.Context(), "authorisation unavailable", "err", err)
        http.Error(w, "service unavailable", http.StatusServiceUnavailable)
        return
    }
    if !allowed {
        http.Error(w, "forbidden", http.StatusForbidden)
        return
    }

    // Delete only after an explicit, successful allow decision.
}

There are systems where availability is more important than strict denial, but that is a risk decision, not a sensible default. If a service intentionally fails open, the exception should be narrow, observable, time-limited, and tested as a security property.

Cleanup errors can undo a successful security decision

Quick detour: resource cleanup is security work too. A handler may authenticate a caller correctly, write a new record, and then fail to close, flush, commit, or release something. The operation can look successful while the durable state says otherwise.

That matters when the resource controls access. A failed transaction commit can leave an account enabled when the caller was told it had been disabled. A failed audit write can leave a sensitive action without the record needed to investigate it. A failed response flush can make a client retry an operation that already changed state.

Go makes deferred cleanup convenient, but defer does not make errors disappear safely.

func writeAudit(ctx context.Context, db *sql.DB, event AuditEvent) error {
    tx, err := db.BeginTx(ctx, nil)
    if err != nil {
        return fmt.Errorf("begin audit transaction: %w", err)
    }

    committed := false
    defer func() {
        if !committed {
            _ = tx.Rollback()
        }
    }()

    if _, err := tx.ExecContext(ctx,
        `INSERT INTO audit_events (kind, subject) VALUES ($1, $2)`,
        event.Kind, event.Subject,
    ); err != nil {
        return fmt.Errorf("insert audit event: %w", err)
    }

    if err := tx.Commit(); err != nil {
        return fmt.Errorf("commit audit event: %w", err)
    }
    committed = true
    return nil
}

Here, rollback is best effort because the commit has not succeeded. In more sensitive workflows, you may need an outbox, durable queue, or reconciliation job. The design question is not "did the function return nil?" It is "what security fact can I safely claim after every failure point?"

Retries turn errors into repeated actions

An error does not always mean that nothing happened. A client can lose its connection after the server commits a payment, creates an account, or rotates a key. The client sees failure and retries. The server sees a second request and performs the action again.

Retry safety comes from idempotency, not optimism. Use an idempotency key tied to the intended operation, store the result durably, and make concurrent attempts converge on one outcome.

func createExport(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    if key == "" {
        http.Error(w, "missing idempotency key", http.StatusBadRequest)
        return
    }

    result, created, err := exports.CreateOnce(r.Context(), userID(r), key)
    if err != nil {
        slog.ErrorContext(r.Context(), "create export failed", "err", err)
        http.Error(w, "could not create export", http.StatusInternalServerError)
        return
    }

    if !created {
        w.Header().Set("X-Idempotent-Replay", "true")
    }
    writeJSON(w, http.StatusAccepted, result)
}

The key itself needs boundaries. Limit its size, bind it to the authenticated user and operation, expire old records, and avoid returning a previous user's result if the lookup is not scoped correctly. Error handling has quietly become authorisation and data isolation.

Logs are another boundary, and errors cross it easily

Detailed errors are valuable to operators and dangerous when copied into logs without thought. They may contain access tokens, password-reset URLs, personal data, SQL fragments, filesystem paths, or request bodies.

Log the event, the stable identifier, and enough structured context to investigate it. Do not log the raw secret merely because an error wrapped it.

slog.WarnContext(ctx, "token rejected",
    "user_id", userID,
    "token_id", tokenID,
    "reason", "expired",
)

Also decide what happens when logging fails. A security event should not be silently discarded, but blocking every request on an unavailable logging backend can create a denial of service. Some systems need a local durable buffer, an append-only journal, or a clearly documented degradation mode.

Test the failures that carry authority

Most tests exercise the happy path and a generic error. Security tests should force each dependency to fail at the point where authority changes.

  • Make the policy service time out and verify that access is denied.
  • Drop the connection after a database commit and retry the request.
  • Make audit storage reject a sensitive operation and check the resulting state.
  • Return malformed, oversized, and ambiguous error data from dependencies.
  • Inspect public responses and logs for secrets and account-existence differences.

Property-based and fault-injection tests are particularly useful here. The useful assertion is often not the exact error string. It is that no failure path grants more authority than the successful path earned.

When reviewing code, follow errors outward. Ask what the caller learns, what state changed, what gets retried, what gets cleaned up, and who can see the log. The line returning the error is rarely the end of the story. It is usually where the security boundary becomes visible.