As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Let me walk you through how to build a secure login system for modern applications, the kind that works when you have many servers working together. Think of it like a hotel with multiple front desks. If you check in at one desk, all the other desks should know you're a guest. That's what we're building.
First, let's talk about JWTs, or JSON Web Tokens. Imagine a digital ID card that your application creates when you log in. This card has information about you: your user ID, your name, and when the card expires. It's signed with a secret key so no one can forge it. The beauty is that any server can check this ID card without needing to ask a central database, "Is this person allowed in?" They just check the signature and the information on the card itself.
Here’s a basic idea of what that card, or token, contains:
// This represents the data inside a JWT
type TokenClaims struct {
UserID string `json:"sub"`
Username string `json:"username"`
Expiry int64 `json:"exp"`
}
Now, let's build the system that makes and checks these digital ID cards. We'll call it the AuthManager. Its job is to handle everything about logging in, giving out tokens, checking them, and refreshing them when they get old.
We start by creating this manager. It needs a few important tools in its toolbox: a private key to sign the tokens, a public key to verify them, and a connection to a shared storage system (like Redis) so all our servers can talk about sessions.
func NewAuthManager(redisAddr string) (*AuthManager, error) {
// 1. Make a strong key pair for signing
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
return nil, fmt.Errorf("cannot create keys: %w", err)
}
// 2. Connect to our shared memory (Redis)
redisClient := redis.NewClient(&redis.Options{Addr: redisAddr})
// 3. Build the manager with all its components
return &AuthManager{
privateKey: privateKey,
publicKey: &privateKey.PublicKey,
redisClient: redisClient,
sessionStore: NewSessionStore(redisClient),
tokenBlacklist: NewTokenBlacklist(redisClient),
config: getDefaultConfig(),
}, nil
}
When someone provides a username and password, the Authenticate method springs into action. I think of this like a bouncer at a club. First, it looks up the guest list (the user database). Then, it checks the password against the stored, encrypted version. If that's all good, it prints two tickets: a fast-pass AccessToken that gets you into the main area for a short time, and a RefreshToken that lets you get a new fast-pass without standing in the login line again.
func (am *AuthManager) Authenticate(ctx context.Context, username, password string) (*AuthResponse, error) {
// Find the user
user, err := am.findUserInDB(ctx, username)
if err != nil {
return nil, ErrInvalidCredentials // "Sorry, not on the list"
}
// Check the password
if !checkPasswordHash(password, user.HashedPassword) {
return nil, ErrInvalidCredentials // "Wrong password"
}
// Make the tickets
accessTicket, err := am.createAccessToken(user)
refreshTicket, err := am.createRefreshToken(user)
// Record this session in the shared log (Redis)
session := &Session{
ID: generateID(),
UserID: user.ID,
RefreshToken: refreshTicket,
}
am.sessionStore.Save(ctx, session)
// Hand the tickets to the user
return &AuthResponse{
AccessToken: accessTicket,
RefreshToken: refreshTicket,
ExpiresIn: 900, // 15 minutes in seconds
}, nil
}
The short-lived access token is crucial. If it gets stolen, the thief only has a small window to use it. Making tokens is straightforward with a good library. We put important info inside and sign it.
func (am *AuthManager) createAccessToken(user *User) (string, error) {
// Token expires in 15 minutes
expiryTime := time.Now().Add(15 * time.Minute)
claims := jwt.MapClaims{
"sub": user.ID, // Subject: Who is this?
"username": user.Username, // Their name
"exp": expiryTime.Unix(), // Expiry timestamp
"jti": generateTokenID(), // Unique Ticket ID
"token_type": "access",
}
// Sign the token with our private key
token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)
signedToken, err := token.SignedString(am.privateKey)
return signedToken, err
}
Now, how do we use this token? In a Go web server, we use a piece of middleware. It's like a security guard standing at the door of every protected room. The guard's only job is to check ID cards.
func AuthMiddleware(authManager *AuthManager) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// The guard asks for your ID
authHeader := r.Header.Get("Authorization")
// Expecting format: "Bearer <your-token-here>"
tokenParts := strings.Split(authHeader, " ")
if len(tokenParts) != 2 {
http.Error(w, "Bad credentials", http.StatusUnauthorized)
return
}
tokenString := tokenParts[1]
// The guard checks the ID's validity and signature
validToken, err := authManager.ValidateToken(r.Context(), tokenString)
if err != nil {
http.Error(w, "Invalid ID", http.StatusUnauthorized)
return
}
// If it's good, the guard lets you through and tells the room who you are.
// They attach your info to the request.
ctx := context.WithValue(r.Context(), "userClaims", validToken.Claims)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
The ValidateToken method is where the real checking happens. It's a meticulous process. First, it checks if the token is on a "revoked" list (like a cancelled credit card). Then, it verifies the cryptographic signature to ensure it's not a fake. Finally, it checks the expiry date and other details.
func (am *AuthManager) ValidateToken(ctx context.Context, tokenString string) (*jwt.Token, error) {
// Step 1: Fast check - is this token blacklisted?
if am.tokenBlacklist.IsRevoked(tokenString) {
return nil, ErrTokenRevoked // "This ticket was cancelled."
}
// Step 2: Verify the signature using the public key
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
return am.publicKey, nil
})
if err != nil || !token.Valid {
return nil, ErrInvalidToken // "Signature is bad or ticket is fake."
}
// Step 3: Check the ticket's printed details
claims, _ := token.Claims.(jwt.MapClaims)
expiry := int64(claims["exp"].(float64))
if time.Unix(expiry, 0).Before(time.Now()) {
return nil, ErrTokenExpired // "This ticket is out of date."
}
return token, nil // "Everything checks out. Welcome."
}
What happens when your 15-minute access token expires? You don't want to type your password again. This is where the refresh token shines. It's a longer-lived token whose only job is to get you a new access token. The process is similar to the initial login, but it uses the refresh token as proof instead of a password.
func (am *AuthManager) RefreshTokens(ctx context.Context, oldRefreshToken string) (*AuthResponse, error) {
// 1. Validate the old refresh token itself
_, err := am.ValidateToken(ctx, oldRefreshToken)
if err != nil {
return nil, err // "Your renewal ticket is no good."
}
// 2. Find the active session linked to this refresh token
session, err := am.sessionStore.FindByRefreshToken(ctx, oldRefreshToken)
if err != nil {
return nil, err // "We have no record of this renewal request."
}
// 3. Get the user associated with the session
user, err := am.findUserInDB(ctx, session.UserID)
// 4. Issue brand new access and refresh tokens
newAccessToken, _ := am.createAccessToken(user)
newRefreshToken, _ := am.createRefreshToken(user)
// 5. Update the session with the new refresh token
session.RefreshToken = newRefreshToken
am.sessionStore.Save(ctx, session)
// 6. Invalidate the old refresh token so it can't be used again
am.tokenBlacklist.Revoke(ctx, oldRefreshToken)
return &AuthResponse{
AccessToken: newAccessToken,
RefreshToken: newRefreshToken,
}, nil
}
Handling logout or token revocation is critical for security. If a user logs out or a token is stolen, we need to make sure it becomes useless instantly, even before its natural expiry. This is the biggest challenge with JWTs, since they are self-contained. Our solution uses a shared blacklist.
We store the unique ID (jti) of revoked tokens in Redis with an expiry time slightly longer than the token's original life. Every time we validate a token, we first check this list.
type TokenBlacklist struct {
redis *redis.Client
}
func (tb *TokenBlacklist) Revoke(ctx context.Context, tokenString string) error {
// Extract the unique JWT ID from the token
tokenID := extractTokenID(tokenString)
// Store it in Redis, set to auto-delete in 24 hours
return tb.redis.Set(ctx, "blacklist:"+tokenID, "1", 24*time.Hour).Err()
}
func (tb *TokenBlacklist) IsRevoked(tokenString string) bool {
tokenID := extractTokenID(tokenString)
// A simple check to see if this ID is on the blacklist
exists, err := tb.redis.Exists(ctx, "blacklist:"+tokenID).Result()
return err == nil && exists > 0
}
To make this scalable for thousands of users, we need to think about sessions. We store active session data in Redis so every server in our cluster can access it. This data might include the user's ID, the current refresh token, and the last time they were active.
func (ss *SessionStore) Save(ctx context.Context, session *Session) error {
// Convert session to JSON
data, _ := json.Marshal(session)
// Save with a key like "session:abc123"
key := "session:" + session.ID
// Set to expire in 7 days, matching refresh token lifetime
return ss.redis.Set(ctx, key, data, 7*24*time.Hour).Err()
}
When you have multiple servers, you also need to prevent users from having too many active sessions. This stops credential sharing and limits resource use. It's easy to check with Redis.
func (am *AuthManager) checkSessionLimit(ctx context.Context, userID string) error {
// Use a Redis Set to track all session IDs for a user
key := "user_sessions:" + userID
count, err := am.redisClient.SCard(ctx, key).Result()
if err != nil {
return err
}
// Let's say a maximum of 5 active sessions
if count >= 5 {
return ErrTooManySessions
}
return nil
}
Security is more than just tokens. We should also protect the login endpoint itself. A rate limiter stops attackers from trying millions of passwords. We can implement this with Redis, counting attempts per IP address.
type RateLimiter struct {
redis *redis.Client
}
func (rl *RateLimiter) AllowLogin(ctx context.Context, ip string) (bool, error) {
key := "login_attempts:" + ip + ":" + time.Now().Format("2006-01-02:15") // per IP per hour
attempts, err := rl.redis.Incr(ctx, key).Result()
if err != nil {
return false, err
}
// First attempt? Set a 1-hour expiry.
if attempts == 1 {
rl.redis.Expire(ctx, key, time.Hour)
}
// Allow a maximum of 10 attempts per hour
return attempts <= 10, nil
}
Bringing it all together in a web server looks like this. Notice how the middleware protects the /profile route, while /login and /refresh are publicly accessible.
func main() {
auth, _ := NewAuthManager("localhost:6379")
mux := http.NewServeMux()
// Public routes
mux.HandleFunc("/login", handleLogin(auth))
mux.HandleFunc("/refresh", handleRefresh(auth))
// Protected route - wrapped with the security guard
profileHandler := http.HandlerFunc(handleProfile)
mux.Handle("/profile", AuthMiddleware(auth)(profileHandler))
log.Println("Server starting on :8080")
http.ListenAndServe(":8080", mux)
}
func handleProfile(w http.ResponseWriter, r *http.Request) {
// This handler can only be reached if the middleware validated the token.
// The user's information is now in the request context.
userClaims := r.Context().Value("userClaims").(jwt.MapClaims)
username := userClaims["username"].(string)
fmt.Fprintf(w, "Hello, %s. This is your protected profile.", username)
}
This system balances security, user experience, and performance. The short-lived access tokens limit damage from theft. The refresh tokens allow a smooth user experience without constant logins. The shared Redis store lets any number of servers work together seamlessly. The blacklist ensures we can react immediately to compromised tokens.
Building this piece by piece helps you understand how each part contributes to a secure, distributed system. You start with a simple token, add validation, then session management, then revocation, and finally wrap it in protective layers like rate limiting. The result is a robust foundation you can trust to handle authentication for your applications.
📘 Checkout my latest ebook for free on my channel!
Be sure to like, share, comment, and subscribe to the channel!
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | Java Elite Dev | Golang Elite Dev | Python Elite Dev | JS Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)