Blog

Session Security: A Modern Approach to User Authentication

On Monday, Feb 24, 2025
post image

Web session hijacking remains a persistent threat to application security, where attackers exploit stolen session cookies to impersonate legitimate users. In these attacks, malicious actors intercept authentication tokens - often through cross-site scripting (XSS) or phishing campaigns - gaining access to user accounts. While JSON Web Tokens (JWT) provide a solid authentication foundation, static implementation patterns can leave systems vulnerable.

This post explores an enhanced authentication architecture that implements dynamic secret keys and client fingerprinting, allowing for granular session control without disrupting the broader user base.

Base concepts

Before jumping into the implementation details, let’s understand two key concepts that form the backbone of our enhanced authentication system.

Dynamic Secret Keys

Unlike traditional JWT implementations that use a single secret key for all users, dynamic secret keys assign a unique secret to each user’s session. This approach provides granular control over session management - if a session is compromised, we can invalidate it by changing only that user’s secret key, leaving other users unaffected. Think of it as having a unique lock for each user’s door, rather than using a master key for the entire building.

Client Fingerprinting

Client fingerprinting creates a unique identifier for each session based on various attributes of the client’s environment. This includes:

  • Browser characteristics
  • IP address information
  • Client type (web, mobile, etc.)
  • Request-specific parameters

By incorporating these fingerprints into our authentication tokens, we add an additional verification layer. Even if an attacker intercepts a valid token, they would need to replicate the exact environment from which it was issued. This significantly increases the difficulty of session hijacking attempts, as possessing the token alone is insufficient for gaining access.

Technical implementation

The enhanced authentication system combines three key elements: dynamic user-specific secret keys, client fingerprinting, and JWT-based session management. This approach allows for individual session invalidation while maintaining a robust defense against token theft.

Authentication Flow Design

The architecture centers around three main components:

  1. User Claims: As we are going to work with the JWT-based session, let’s define the claims to generate the auth token next.

// internal/models/user_claims.go

import "github.com/golang-jwt/jwt/v5"

type UserClaims struct {
    jwt.RegisteredClaims
    Username    string `json:"username"`
    Fingerprint string `json:"fingerprint"`
    ClientType  string `json:"client_type"`
}

Claims are statements about an entity (typically, the user) and additional data. In this case we are going to use the golang-jwt/jwt/v5 package to manage and define JWTs claims.

  1. Fingerprint Management: As we could see on the User Claims, there is a specific fingerprint associated with each user. The first part of the fingerprint will be generated by the client, and sent via header. Then, create the final fingerprint, we will take the IP and the User Agent associated with the HTTP requests.

// internal/auth/fingerprint/manager.go

type Params struct {
    ClientType        models.ClientType
    ClientFingerprint string
    Ip                string
    UserAgent         string
}

type Manager struct {
    baseFingerprint map[string]*models.BaseFingerprint
}

func NewManager() *Manager {
    return &Manager{
        baseFingerprint: make(map[string]*models.BaseFingerprint),
    }
}

func (m *Manager) GenerateFingerprint(params Params) (string, error) {
    base := models.BaseFingerprint{
        ClientType: params.ClientType,
        IP:         params.Ip,
        UserAgent:  params.UserAgent,
    }

    fingerprintHash := base.Hash()

    return fingerprintHash, nil
}

Different hacking techniques could spoof headers, however, by adding various layers of security, we could enhance and mitigate the security issues.

  1. Token Management: With the claims and fingerprint setup, we can proceed to implement the JWT logic to authenticate and verify the user token:

// internal/auth/token/manager.go

type Manager struct {
    Conn          *db.Connection
    TokenDuration time.Duration
}

type Params struct {
    Username    string
    Fingerprint string
    ClientType  models.ClientType
    Secret      []byte
}

type ManagerConfig struct {
    Conn          *db.Connection
    TokenDuration time.Duration
}

func NewManager(config ManagerConfig) *Manager {
    return &Manager{
        Conn:          config.Conn,
        TokenDuration: config.TokenDuration,
    }
}

func (m *Manager) GenerateToken(params Params) (string, error) {
    now := time.Now()

    // Create claims with standard JWT claims and custom fields
    claims := &models.UserClaims{
        RegisteredClaims: jwt.RegisteredClaims{
            ExpiresAt: jwt.NewNumericDate(now.Add(m.TokenDuration)),
            IssuedAt:  jwt.NewNumericDate(time.Now()),
            NotBefore: jwt.NewNumericDate(now),
            Subject:   params.Username,
        },
        Username:    params.Username,
        Fingerprint: params.Fingerprint,
        ClientType:  string(params.ClientType),
    }

    token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)

    // Sign and get the complete encoded token as a string
    tokenString, err := token.SignedString(params.Secret)
    if err != nil {
        return "", fmt.Errorf("failed to sign token: %w", err)
    }

    return tokenString, nil
}

func (m *Manager) VerifyToken(tokenString, currentFingerprint string) (*models.UserClaims, error) {
    parser := jwt.NewParser(jwt.WithoutClaimsValidation())

    token, _ := parser.ParseWithClaims(tokenString, &models.UserClaims{}, func(t *jwt.Token) (interface{}, error) {
        return nil, nil // We'll verify later with the correct secret
    })

    // Extract preliminary claims to get the user ID
    prelimClaims, ok := token.Claims.(*models.UserClaims)
    if !ok {
        return nil, ErrInvalidClaims
    }

    // Get user's secret from database
    user, err := m.Conn.GetUser(prelimClaims.Username)
    if err != nil {
        return nil, ErrInvalidToken
    }

    // Now parse and validate with the correct user secret
    validToken, err := jwt.ParseWithClaims(tokenString, &models.UserClaims{}, func(t *jwt.Token) (interface{}, error) {
        if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok {
            return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
        }
        return []byte(user.Secret), nil
    })

    if err != nil {
        if errors.Is(err, jwt.ErrTokenExpired) {
            return nil, ErrTokenExpired
        }
        return nil, fmt.Errorf("failed to parse token: %w", err)
    }

    if !validToken.Valid {
        return nil, ErrInvalidToken
    }


    claims, ok := validToken.Claims.(*models.UserClaims)
    if !ok {
        return nil, ErrInvalidClaims
    }

    // Verify fingerprint
    if claims.Fingerprint != currentFingerprint {
        return nil, ErrInvalidFingerprint
    }

    return claims, nil
}

Let’s continue the implementation flow, considering our layered security approach. First of all, let’s add a handler struct to attach the fingerprint and token managers, as well as the DB connection to make the transactions.


// internal/handlers/handlers.go

type Handler struct {
    fingerprintManager *fingerprint.Manager
    tokenManager       *token.Manager
    conn               *db.Connection
}

func NewHandler(fm *fingerprint.Manager, tm *token.Manager, conn *db.Connection) *Handler {
    return &Handler{
        fingerprintManager: fm,
        tokenManager:       tm,
        conn:               conn,
    }
}

Now, let’s implement the login handler.

First, we process the login request, validating a custom header to determine where the request came from. Considering that we can accept requests from other clients, like mobile apps. Then, we validate user credentials:


// internal/handlers/login.go
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
    clientType := models.ClientType(r.Header.Get("X-Client-Type"))
    if !clientType.IsValid() {
        http.Error(w, "Invalid client type", http.StatusBadRequest)
        return
    }

    var req models.LoginRequest

    if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
        http.Error(w, "Invalid request body", http.StatusBadRequest)
        return
    }

    err := h.conn.GetUser(req.Username)

    if err != nil {
        http.Error(w, "User not found", http.StatusNotFound)
        return
    }
}

Then, after validating the user and matching with the hardcoded password, we proceed to extract the client fingerprint and get the IP from the request to start to build the params to create the final fingerprint:


if req.Password == os.Getenv("PASSWORD") {
    clientFingerprint := utils.SanitizeHeader(r.Header.Get("X-Fingerprint"))

    ip, err := utils.GetIP(r)
    if err != nil {
        http.Error(w, "Failed to get IP", http.StatusInternalServerError)
        return
    }

    fingerprintParams := fingerprint.Params{
        ClientType:        clientType,
        ClientFingerprint: clientFingerprint,
        Ip:                ip,
        UserAgent:         utils.SanitizeHeader(r.UserAgent()),
    }

    newFingerprint, err := h.fingerprintManager.GenerateFingerprint(fingerprintParams)

    if err != nil {
        http.Error(w, "Failed to generate fingerprint", http.StatusInternalServerError)
        return
    }
}

The key innovation lies in generating a user-specific secret key for each session. This approach allows us to invalidate individual sessions without affecting other users. The user entity contains a string field, which refers to a particular secret key generated when the user is created. Now, let’s proceed to build the user token based on the specific user params, and deliver the token through HTTP-only cookies with strict security parameters:


tokenParams := token.Params{
    Username:    req.Username,
    Fingerprint: newFingerprint,
    ClientType:  clientType,
    Secret:      []byte(user.Secret),
}

// Generate token
newToken, err := h.tokenManager.GenerateToken(tokenParams)
if err != nil {
    http.Error(w, "Failed to generate token", http.StatusInternalServerError)
    return
}

// For web clients, set the cookie
if clientType == models.WebClient {
    http.SetCookie(w, &http.Cookie{
        Name:     "session",
        Value:    newToken,
        Path:     "/",
        HttpOnly: true,
        Secure:   true,
        SameSite: http.SameSiteNoneMode,
        MaxAge:   int(h.tokenManager.TokenDuration),
    })
}

response := models.LoginResponse{
    Success:         true,
    Message:         "Login successful",
    SessionDuration: int(h.tokenManager.TokenDuration),
}

w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(response)

Building upon our login handler implementation, we can see how each security layer works together to establish a secure session. The backend generates a user-specific secret key, creates a fingerprint based on request attributes, and delivers the token via secure HTTP-only cookies.

We need a client-side implementation that can seamlessly interact with these security mechanisms to complete this system. While the backend handles the heavy lifting of security, the frontend needs to provide the necessary information and manage the authentication flow from the user’s perspective.

Let’s explore how to build a lightweight yet secure frontend implementation using Vanilla Javascript:


const fingerprint = `${window.screen.width}x${window.screen.height}-${navigator.language}-${window.screen.colorDepth}`;
async function fetchData() {
    try {
        const response = await fetch(apiServerUrl + "/api/auth/login", {
            method: 'POST',
            credentials: 'include',
            headers: {
                'Content-Type': 'application/json',
                'X-Client-Type': 'web',
                'X-Fingerprint': fingerprint
            },
            body: JSON.stringify({
                username: username.value,
                password: password.value
            })
        })
        if (!response.ok) {
            throw new Error('Login failed');
        }

        return await response.json();

        // Handle the response data here
        } catch (error) {
            console.error('Login error:', error);
            throw error;
        }
}

The client-side implementation includes different security considerations:

  • The fingerprint is generated using multiple browser characteristics.
  • Credentials are sent over HTTPS with appropriate headers.
  • Cookie handling is enabled with credentials: 'include'.

After establishing our login system’s foundation, we need a way to protect sensitive routes and verify user sessions. The authentication middleware, combined with a verify endpoint, creates a robust barrier against unauthorized access.

Let’s explore the auth middleware, to filter all the requests associated with protected routes. Initially, let’s examine the middleware definition:


// internal/middleware/middleware.go

type Middleware struct {
    fingerprintManager *fingerprint.Manager
    tokenManager       *token.Manager
}

func NewMiddleware(fm *fingerprint.Manager, tm *token.Manager) *Middleware {
    return &Middleware{
        fingerprintManager: fm,
        tokenManager:       tm,
    }
}

The middleware structure itself encapsulates the fingerprint and token managers, providing a clean interface for route protection:

And the auth middleware:


// internal/middleware/auth.go
func (m *Middleware) AuthMiddleware(next http.HandlerFunc) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        // Extract token from cookie
        cookie, err := r.Cookie("session")
        if err != nil {
            if errors.Is(err, http.ErrNoCookie) {
                w.WriteHeader(http.StatusUnauthorized)
                return
            }
            w.WriteHeader(http.StatusBadRequest)
            return
        }

        clientType := models.ClientType(r.Header.Get("X-Client-Type"))
        if !clientType.IsValid() {
            http.Error(w, "Invalid client type", http.StatusBadRequest)
            return
        }

        ip, err := utils.GetIP(r)
        if err != nil {
            http.Error(w, "Failed to get IP", http.StatusInternalServerError)
            return
        }

        //get token from cookie
        tokenString := cookie.Value

        clientFingerprint := utils.SanitizeHeader(r.Header.Get("X-Fingerprint"))
        if clientFingerprint == "" {
            http.Error(w, "Missing Fingerprint", http.StatusUnauthorized)
            return
        }


        fingerprintParams := fingerprint.Params{
            ClientType:        clientType,
            ClientFingerprint: clientFingerprint,
            Ip:                ip,
            UserAgent:         utils.SanitizeHeader(r.UserAgent()),
        }

        newFingerprint, err := m.fingerprintManager.GenerateFingerprint(fingerprintParams)

        if err != nil {
            http.Error(w, "Failed to generate fingerprint", http.StatusInternalServerError)
            return
        }

        // Verify token
        claims, err := m.tokenManager.VerifyToken(tokenString, newFingerprint)
        if err != nil {
            switch {
                case errors.Is(err, token.ErrTokenExpired):
                    http.Error(w, "Token expired", http.StatusUnauthorized)
                case errors.Is(err, token.ErrInvalidFingerprint):
                    http.Error(w, "Invalid fingerprint", http.StatusUnauthorized)
                default:
                    http.Error(w, "Invalid token", http.StatusUnauthorized)
            }
            return
        }

        // Add validated claims to request context
        ctx := context.WithValue(r.Context(), utils.ClaimsKey, claims)
        next.ServeHTTP(w, r.WithContext(ctx))
    }
}

The authentication middleware serves as a security checkpoint for protected routes, implementing different layers of validation:

  • Session Verification: Checks for the presence and validity of the session cookie.
  • Client Validation: Ensures requests come from valid client types with proper fingerprinting.
  • Token Management: Verifies JWT tokens and their associated fingerprints.
  • Context Enhancement: Adds validated user claims to the request context for downstream handlers.

With our authentication middleware in place, we can now implement a verification endpoint that allows clients to validate their session status. This endpoint serves as a crucial component for maintaining secure user sessions.

The Verify handler operates on requests that have already passed through our authentication middleware, leveraging the enriched context to provide session information:


// internal/handlers/verify.go
func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) {
    claims := r.Context().Value(utils.ClaimsKey).(*models.UserClaims)

    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(map[string]interface{}{
        "username":   claims.Username,
        "clientType": claims.ClientType,
        "valid":      true,
    })
}

While the verify endpoint serves as a sentinel for validating user sessions, it represents just one component of our comprehensive security architecture. Its simplicity lies in the sophisticated security mechanisms working beneath the surface.

Looking at the broader security landscape, this implementation embodies a modern approach to user authentication that acknowledges an important reality: security risks are persistent and evolving. By layering multiple security mechanisms – from user-specific secret keys to browser fingerprinting – we create a more resilient system against various attack vectors.

Let’s examine the key aspects that make this approach effective:

  • Dynamic Session Management

    • User-specific secret keys allow for individual session invalidation
    • Fingerprint validation adds an extra layer of authentication
    • Client-type awareness enables flexible security policies across different platforms
  • Granular Control

    • Ability to revoke individual sessions without affecting other users
    • Context-aware authentication through browser fingerprinting
    • Detailed error handling for different security scenarios
  • Security by Design

    • Secure cookie handling with appropriate flags
    • Protection against token theft through fingerprint validation
    • Clear separation of concerns in middleware and handlers

This multi-layered security approach demonstrates how modern web applications can maintain robust protection while providing a seamless user experience.

Conclusion

As web applications become more sophisticated, security challenges continue to grow. The approach outlined in this post shows that effective security can coexist with good user experience and system flexibility. By implementing user-specific secrets, dynamic fingerprinting, and precise session control, we create a security framework that’s both strong and adaptable. While no system is completely secure, this architecture provides robust protection against common security threats while maintaining the ability to respond quickly to new challenges. The future of web security isn’t about building impenetrable barriers, but about creating intelligent, adaptive systems that can effectively handle security challenges as they emerge.

Share this post: