Earlier, we explored session management in Go web applications, followed by a deep dive into CSRF protection. Drawing from my decade of experience building web applications with Laravel, I've found that good old session-based authentication remains the most convenient and secure way to authenticate users. It strikes the perfect balance between simplicity and security, making it an approach I trust for building reliable web apps.
The idea behind session-based authentication is simple:
Client Server
| |
| 1. User visits the web app |
|----------------------------------------> |
| |
| 2. Server creates a session and sends |
| its ID back |
| <--------------------------------------- |
| |
| 3. User submits login form |
|----------------------------------------> |
| |
| 4. Server verifies credentials and |
| stores the user identifier in the |
| session |
| |
| 5. Server sends a success response |
| <----------------------------------------|
| |
| 6. Client makes authenticated requests |
| (Cookie is automatically sent) |
|----------------------------------------> |
| |
| 7. Server retrieves session data and |
| extracts the user identifier |
| |
| 8. Server processes request and |
| responds |
| <----------------------------------------|
When a session is first created, before the visitor submits the login form, it is known as an unauthenticated session. At this stage, the session is not yet linked to a specific user, but it can still store temporary data that enhances the user experience.
For example, in an e-commerce application, an unauthenticated session can store items added to a shopping cart before the user logs in. In a blog or forum, an unauthenticated session might be used to save a visitor’s draft comments before they sign up. In general, unauthenticated sessions can be used to store flash messages, such as "Item added to cart" or "Invalid login credentials".
Once the login form is submitted and the user's identity is verified, storing the user identifier in the session converts it into an authenticated session. Each time the client sends the cookie containing the session ID, the server uses it to identify the associated user and tailor the response accordingly.
The first step in implementing an authentication mechanism in our web application is to enable user registration. During this process, a user typically provides an email or username along with a password. The server then performs input sanitization and various validation checks to ensure the credentials are secure, properly formatted, and unique.
Once these checks are passed, the server securely stores the user’s credentials, often hashing the password using a strong algorithm like bcrypt
or Argon2
before saving it to the database.
Since the scope of this articles covers only the authentication part, we'll skip input validation and sanitization and focus on hashing the password and storing it in a database:
import (
"database/sql"
"golang.org/x/crypto/bcrypt"
)
func Register(
db *sql.DB,
username string,
password string,
) error {
hashedPassword, err := bcrypt.GenerateFromPassword(
[]byte(password),
bcrypt.DefaultCost,
)
if err != nil {
return err
}
// ...
return nil
}
The first step in our Register
method is to generate a secure hash for the provided password using the crypto/bcrypt
package. This is a crucial security measure that helps protect user credentials in case the database is ever compromised. Hashing passwords ensures that even if an attacker, or a rogue employee, gains access to the database, they cannot easily retrieve the original password.
The bcrypt
algorithm includes a cost factor, which controls how computationally expensive it is to hash a password and verify it later. A higher cost factor increases security by making brute-force attacks more difficult, but it also slows down the hashing process.
In our example, we use the default cost of 10, which strikes a good balance between security and performance. Popular frameworks, such as Laravel, set the default cost to 12 for added protection.
Next, we'll store the username
along with the hashedPassword
in the database:
hashedPassword, err := bcrypt.GenerateFromPassword(...)
// ...
_, err = db.Exec(
"INSERT INTO users (username, password) VALUES (?, ?)",
username,
hashedPassword,
)
if err != nil {
return err
}
With this in place, we now have a record in the database that allows us to verify user credentials when they attempt to log in.
For session-based authentication, the login process consists of two key steps:
- Verify that the user exists and the provided password matches the stored hash.
- Store the authenticated user's information in the session to maintain their logged-in state.
In the first step, we retrieve the user record from the database using the provided username and compare the given password with its hashed version. We do that in a VerifyCredentials
function:
func VerifyCredentials(
db *sql.DB,
username string,
password string,
) error {
var passwordInDB string
err := db.QueryRow(
"SELECT password FROM users WHERE username = ?",
username,
).Scan(&passwordInDB)
if err != nil {
return fmt.Errorf("invalid username: %w", err)
}
err = bcrypt.CompareHashAndPassword(
[]byte(passwordInDB),
[]byte(password),
)
if err != nil {
return fmt.Errorf("invalid password: %w", err)
}
return nil
}
The bcrypt.CompareHashAndPassword
function allows us to verify whether a given plain-text password (password
) matches a bcrypt-hashed password (passwordInDB
). Since the bcrypt algorithm generates a unique salt for each hash, hashing the same password multiple times will always produce different results. This means we cannot simply hash the plain password and compare it directly to the stored hash in a database query. Instead, CompareHashAndPassword
extracts the salt from the stored hash and uses it to perform the comparison securely.
Next, for storing the user in the session, we will add a Login
function:
func Login(
r *http.Request,
sm *SessionManager,
username string,
) error {
session := GetSession(r)
err := sm.migrate(session)
if err != nil {
return fmt.Errorf("failed to migrate session: %w", err)
}
session.Put("username", username)
return nil
}
The Login
function takes in the request, the session manager, and the username. It retrieves the session from the request using the GetSession
function (which we covered in a previous article), then migrates the session by deleting the existing session from the session store, assigning a new session ID, and generating a fresh CSRF token. Finally, it stores the username in the session to maintain the user's authenticated state.
Generating a new session ID when a user's privilege level changes is crucial for protecting against session fixation attacks. Without this step, an attacker could inject a known session ID into the user's session cookie before they authenticate, and then reuse this session ID after the user logs in. This would grant the attacker unauthorized access to the user's account.
The migrate
method looks like this:
func (m *SessionManager) migrate(session *Session) error {
err := m.store.destroy(session.id)
if err != nil {
return err
}
session.id = generateSessionId()
session.Put("csrf_token", generateCSRFToken())
return nil
}
As you can see, the old session is deleted while the in-memory session instance gets a new session ID and csrf_token
.
Before sending the response, the session manager's middleware will include the new session ID in the response, and store the session as a new entry in our session store. For a deeper dive into how this works, refer to the earlier article.
To log a user out, we must migrate the session and remove the username
from the session data:
func Logout(
r *http.Request,
sm *SessionManager,
) error {
session := GetSession(r)
err := sm.migrate(session)
if err != nil {
return fmt.Errorf("failed to migrate session: %w", err)
}
session.Put("username", "")
return nil
}
By migrating the session, we ensure that the existing authenticated session is removed from the session store and a new one is created with the same session data. In the Logout
function, we remove the username
from the session to effectively convert it into an unauthenticated session. If any other critical information is stored in the session, it should also be removed. However, keeping non-critical session data, such as user preferences or timezone settings, can help provide a smooth user experience.
Now, we’ll add middleware to protect handlers from unauthenticated access. Within the middleware, we’ll query the database to verify that the username stored in the session exists, ensuring the session belongs to a valid user:
func Auth(db *sql.DB, next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
session := GetSession(r)
username := session.Get("username").(string)
if username == "" {
http.Error(w, "Unauthenticated", http.StatusForbidden)
return
}
var exists bool
err := db.QueryRow(
"SELECT EXISTS(SELECT 1 FROM users WHERE username = ?)",
username,
).Scan(&exists)
if err != nil {
http.Error(w, "Unauthenticated", http.StatusForbidden)
return
}
if !exists {
http.Error(w, "Unauthenticated", http.StatusForbidden)
return
}
})
}
As you might have guessed, this middleware must run after the session has been added to the request. In other words, the session manager's middleware must execute before the authentication middleware. Otherwise, the authentication middleware will panic when calling the GetSession
function, as it expects the session to be present in the request context.
func GetSession(r *http.Request) *Session {
session, ok := r.Context().Value(sessionContextKey{}).(*Session)
if !ok {
panic("session not found in request context")
}
return session
}
In our middleware, we respond with a 403 Forbidden
error if authentication fails. Alternatively, you could choose to redirect the user to a /login
page, allowing them to enter their credentials and start a new authenticated session.
When handling login requests, it's best practice to ensure the authentication process takes a constant amount of time, regardless of whether the credentials are correct or not. This helps prevent timing attacks, which is a type of attacks where an attacker measures the time it takes for the server to respond and uses that information to infer sensitive data, such as valid usernames or partial password matches.
To protect against timing attacks, we'll start a timer at the beginning of our handler and invoke a time.Sleep
call to ensure the handler responds exactly after a certain duration:
mux.HandleFunc("/login", func(w http.ResponseWriter, r *http.Request) {
const duration = 1 * time.Second
startTime := time.Now()
err := auth.VerifyCredentials(
db,
r.FormValue("username"),
r.FormValue("password"),
)
if err == nil {
err = auth.Login(r, sm, r.FormValue("username"))
}
if time.Now().Sub(startTime) < duration {
time.Sleep(duration - time.Now().Sub(startTime))
}
if err != nil {
http.Error(w, "Invalid credentials", http.StatusUnauthorized)
return
}
http.Redirect(w, r, "/dashboard", http.StatusSeeOther)
})
Here, we set the handler's duration to 1 second, but you can adjust this based on your application's performance needs and the desired user experience. Next, we call the VerifyCredentials
function to check if the provided credentials are valid. If they are, we proceed with the Login
function.
Before returning either a success or failure response, we measure the elapsed time and call time.Sleep
if it’s less than 1 second, ensuring a consistent response time to mitigate timing attacks.
By storing the authenticated user identifier in the session, we can easily differentiate between authenticated and non-authenticated sessions. This allows us to retrieve user information at any point during the request lifecycle, ensuring that we can verify and tailor responses based on the user's authentication status.
The process is simple: we begin by extracting the session from the request, and then check the username session attribute:
session := GetSession(r)
username := session.Get("username").(string)
if username == "" {
// No user (un-authenticated session)
} else {
// We have a user (authenticated session)
}
If the username exists, it indicates that the session is authenticated, and we can proceed to access relevant user data. If not, we know that the session is not authenticated, and we can prompt the user to log in.