Authsome

Account Lockout

Configurable failed-attempt tracking and automatic account lockout to defend against brute-force attacks.

Authsome's account lockout system automatically suspends authentication for a user after too many consecutive failed sign-in attempts. When a lockout is triggered, further sign-in attempts are rejected regardless of whether the credentials are correct. This prevents brute-force and credential-stuffing attacks without requiring you to write custom rate-limiting code.

How it works

On every failed authentication attempt (wrong password, expired MFA code, etc.), Authsome increments a failure counter for the user. When the counter reaches MaxAttempts, the account is locked for LockoutDuration. The counter resets to zero after ResetAfter without a new failure, so a single forgotten password doesn't permanently affect a user who guesses correctly on attempt N.

Attempt 1 (fail): counter = 1
Attempt 2 (fail): counter = 2
Attempt 3 (fail): counter = 3 → MaxAttempts reached → lock for LockoutDuration
Attempt 4 (success during lockout): rejected with ErrAccountLocked
...
After LockoutDuration: lock lifted, counter reset to 0

Configuration

import (
    "github.com/xraph/authsome"
    "github.com/xraph/authsome/lockout"
)

auth := authsome.New(
    authsome.WithLockout(lockout.Config{
        MaxAttempts:     5,
        LockoutDuration: 15 * time.Minute,
        ResetAfter:      1 * time.Hour,
    }),
)
FieldTypeDefaultDescription
MaxAttemptsint5Number of consecutive failures before locking the account
LockoutDurationtime.Duration15mHow long the account stays locked after MaxAttempts is reached
ResetAftertime.Duration1hHow long without a failure before the counter resets to zero
EnabledbooltrueSet to false to disable lockout globally
WhitelistIPs[]stringnilIP addresses exempt from lockout (e.g., internal health check IPs)

For a general-purpose consumer application:

lockout.Config{
    MaxAttempts:     5,
    LockoutDuration: 30 * time.Minute,
    ResetAfter:      2 * time.Hour,
}

For a high-security internal application:

lockout.Config{
    MaxAttempts:     3,
    LockoutDuration: 24 * time.Hour,
    ResetAfter:      4 * time.Hour,
}

The LockoutTracker interface

Authsome tracks lockout state through the LockoutTracker interface. You can replace the built-in tracker with your own implementation backed by Redis, a database, or any other store.

type LockoutTracker interface {
    // Increment increments the failure counter for the user and returns the new count.
    Increment(ctx context.Context, userID string) (int, error)

    // Get returns the current failure count and whether the account is currently locked.
    Get(ctx context.Context, userID string) (count int, lockedUntil *time.Time, err error)

    // Lock locks the account for the given duration.
    Lock(ctx context.Context, userID string, until time.Time) error

    // Reset clears the failure counter and any active lock for the user.
    Reset(ctx context.Context, userID string) error
}

Built-in: memory tracker

The memory tracker is the default. It stores state in a Go map protected by a sync.RWMutex. It is suitable for single-instance applications, testing, and development.

import "github.com/xraph/authsome/lockout"

tracker := lockout.NewMemoryTracker()

auth := authsome.New(
    authsome.WithLockoutTracker(tracker),
    authsome.WithLockout(lockout.Config{
        MaxAttempts:     5,
        LockoutDuration: 15 * time.Minute,
        ResetAfter:      1 * time.Hour,
    }),
)

The memory tracker does not persist state across restarts. An attacker can bypass lockout by triggering a process restart. For production deployments, use a Redis or database-backed tracker.

Custom tracker: Redis example

package redislockout

import (
    "context"
    "fmt"
    "time"

    "github.com/redis/go-redis/v9"
    "github.com/xraph/authsome/lockout"
)

type RedisTracker struct {
    client *redis.Client
    reset  time.Duration
}

func New(client *redis.Client, resetAfter time.Duration) lockout.LockoutTracker {
    return &RedisTracker{client: client, reset: resetAfter}
}

func (r *RedisTracker) Increment(ctx context.Context, userID string) (int, error) {
    key := fmt.Sprintf("lockout:count:%s", userID)
    count, err := r.client.Incr(ctx, key).Result()
    if err != nil {
        return 0, err
    }
    // Set expiry only on first increment (reset window).
    if count == 1 {
        r.client.Expire(ctx, key, r.reset)
    }
    return int(count), nil
}

func (r *RedisTracker) Get(ctx context.Context, userID string) (int, *time.Time, error) {
    countKey := fmt.Sprintf("lockout:count:%s", userID)
    lockKey  := fmt.Sprintf("lockout:lock:%s", userID)

    count, err := r.client.Get(ctx, countKey).Int()
    if err == redis.Nil {
        count = 0
    } else if err != nil {
        return 0, nil, err
    }

    ttl, err := r.client.TTL(ctx, lockKey).Result()
    if err != nil || ttl <= 0 {
        return count, nil, nil
    }
    until := time.Now().Add(ttl)
    return count, &until, nil
}

func (r *RedisTracker) Lock(ctx context.Context, userID string, until time.Time) error {
    key := fmt.Sprintf("lockout:lock:%s", userID)
    return r.client.Set(ctx, key, "1", time.Until(until)).Err()
}

func (r *RedisTracker) Reset(ctx context.Context, userID string) error {
    pipe := r.client.Pipeline()
    pipe.Del(ctx, fmt.Sprintf("lockout:count:%s", userID))
    pipe.Del(ctx, fmt.Sprintf("lockout:lock:%s", userID))
    _, err := pipe.Exec(ctx)
    return err
}

Register the custom tracker:

import (
    redislockout "myapp/lockout/redis"
    "github.com/redis/go-redis/v9"
)

rdb := redis.NewClient(&redis.Options{Addr: "localhost:6379"})

auth := authsome.New(
    authsome.WithLockoutTracker(redislockout.New(rdb, time.Hour)),
    authsome.WithLockout(lockout.Config{
        MaxAttempts:     5,
        LockoutDuration: 15 * time.Minute,
        ResetAfter:      1 * time.Hour,
    }),
)

Admin operations

Administrators can manually unlock accounts and inspect lockout state:

// Check if a user is locked out.
count, lockedUntil, err := auth.Lockout().GetStatus(ctx, userID)
if lockedUntil != nil {
    fmt.Printf("user %s locked until %s (after %d failures)\n",
        userID, lockedUntil.Format(time.RFC3339), count)
}

// Manually unlock an account (e.g., after verifying identity via support).
err := auth.Lockout().Reset(ctx, userID)

// Manually lock an account.
until := time.Now().Add(24 * time.Hour)
err := auth.Lockout().Lock(ctx, userID, until)

HTTP API endpoints

MethodPathDescription
GET/admin/users/:user_id/lockoutGet the current lockout status for a user
DELETE/admin/users/:user_id/lockoutManually unlock an account
POST/admin/users/:user_id/lockoutManually lock an account

These admin endpoints require the manage:user RBAC permission. Expose them only to internal admin tooling.

Events emitted

EventTrigger
auth.signin.failedA failed sign-in attempt was recorded (includes current count in metadata)
auth.lockout.triggeredThe account was locked after reaching MaxAttempts
auth.lockout.liftedThe lockout expired or was manually cleared

On this page