DEV Community

Shrijith Venkatramana
Shrijith Venkatramana

Posted on

Golang Echo Authentication: Simple to Powerful Techniques

Hi there! I'm Shrijith Venkatrama, founder of Hexmos. Right now, I’m building LiveAPI, a first of its kind tool for helping you automatically index API endpoints across all your repositories. LiveAPI helps you discover, understand and use APIs in large tech infrastructures with ease.

Authentication is a core part of most web apps, and getting it right in the Echo framework for Golang can be both straightforward and powerful. Echo is a lightweight, fast, and developer-friendly framework, but its flexibility means you need to understand how to implement auth properly—whether you're building a simple login or a complex JWT-based system. In this guide, we'll walk through basic to advanced authentication techniques, complete with runnable code examples, tables for clarity, and practical tips to make your app secure and scalable.

We'll cover:

  • Setting up a basic auth system
  • Adding middleware for protected routes
  • Implementing JWT-based authentication
  • Handling refresh tokens
  • Securing APIs with role-based access

Let’s dive in and build some secure, practical auth systems with Echo.

Starting Simple: Basic Authentication with Echo

Basic authentication is the simplest way to secure an endpoint. It uses a username and password sent in the HTTP header (encoded in Base64). While not ideal for production due to its simplicity, it’s a great starting point for understanding Echo’s middleware and auth flow.

Key Concepts

  • Basic Auth Middleware: Echo provides a built-in BasicAuth middleware to handle username/password validation.
  • Use Case: Quick prototyping or internal APIs with low-security needs.
  • Security Note: Always use HTTPS in production to prevent credential exposure.

Here’s a complete example of a basic auth setup:

package main

import (
    "net/http"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
)

func main() {
    e := echo.New()

    // Define a protected route
    protected := e.Group("/protected", middleware.BasicAuth(func(username, password string, c echo.Context) (bool, error) {
        // Hardcoded for demo; use a database in production
        if username == "admin" && password == "secret" {
            return true, nil
        }
        return false, nil
    }))

    protected.GET("/dashboard", func(c echo.Context) error {
        return c.String(http.StatusOK, "Welcome to the protected dashboard!")
    })

    e.Logger.Fatal(e.Start(":8080"))
}

// Run this and access http://localhost:8080/protected/dashboard
// Use username: admin, password: secret
// Expected output: "Welcome to the protected dashboard!"
Enter fullscreen mode Exit fullscreen mode

How It Works

  1. The BasicAuth middleware checks the Authorization header for Base64-encoded credentials.
  2. If the username and password match, the request proceeds to the /dashboard route.
  3. If not, Echo returns a 401 Unauthorized response.

When to Use

Scenario Use Basic Auth? Why?
Internal tools Yes Simple and quick to set up
Public APIs No Lacks encryption without HTTPS
Production apps No Better options like JWT exist

Tip: For real apps, store credentials in a database and hash passwords using bcrypt. Basic auth is too exposed for public-facing apps.

Locking Down Routes: Custom Middleware for Auth

Basic auth is limited, so let’s step it up with custom middleware to protect routes. This approach gives you control to check user sessions, API keys, or custom tokens. We’ll build a middleware that checks for an API key in the request header.

Why Custom Middleware?

  • Flexibility: You define the auth logic (e.g., API keys, tokens, or database checks).
  • Reusability: Apply it to multiple routes or groups.
  • Scalability: Easily extend to complex checks like role-based access.

Here’s an example that checks for an X-API-Key header:

package main

import (
    "net/http"
    "github.com/labstack/echo/v4"
)

func main() {
    e := echo.New()

    // Custom middleware to check API key
    apiKeyMiddleware := func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            apiKey := c.Request().Header.Get("X-API-Key")
            // In production, validate against a database or env variable
            if apiKey != "my-secret-key" {
                return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid API key"})
            }
            return next(c)
        }
    }

    // Apply middleware to a route group
    protected := e.Group("/api", apiKeyMiddleware)
    protected.GET("/data", func(c echo.Context) error {
        return c.JSON(http.StatusOK, map[string]string{"message": "Access granted to API data"})
    })

    e.Logger.Fatal(e.Start(":8080"))
}

// Run and test with: curl -H "X-API-Key: my-secret-key" http://localhost:8080/api/data
// Expected output: {"message":"Access granted to API data"}
// Without key: {"error":"Invalid API key"}
Enter fullscreen mode Exit fullscreen mode

Key Points

  • Header-Based Auth: The middleware checks the X-API-Key header.
  • Error Handling: Returns a JSON response for invalid keys, making it API-friendly.
  • Extensibility: You can modify the middleware to check a database or external service.

Pro Tip: Store API keys in environment variables or a secure vault like HashiCorp Vault for production.

Leveling Up: JWT Authentication for Secure APIs

JSON Web Tokens (JWT) are a popular choice for stateless authentication. They encode user info and a signature in a token, which clients send with requests. Echo’s JWT middleware makes this easy.

Why JWT?

  • Stateless: No server-side session storage needed.
  • Scalable: Works well for microservices and mobile apps.
  • Secure: Signed tokens prevent tampering (when configured correctly).

Steps to Implement

  1. Generate a JWT on user login.
  2. Use Echo’s JWT middleware to validate tokens.
  3. Protect routes with the middleware.

Here’s a complete JWT example:

package main

import (
    "net/http"
    "time"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/golang-jwt/jwt/v4"
)

func main() {
    e := echo.New()

    // Login route to generate JWT
    e.POST("/login", func(c echo.Context) error {
        type Login struct {
            Username string `json:"username"`
            Password string `json:"password"`
        }
        var login Login
        if err := c.Bind(&login); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid input"})
        }

        // Validate credentials (simplified for demo)
        if login.Username != "user" || login.Password != "pass" {
            return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"})
        }

        // Create JWT token
        token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
            "username": login.Username,
            "exp":      time.Now().Add(time.Hour * 1).Unix(),
        })
        tokenString, err := token.SignedString([]byte("my-secret"))
        if err != nil {
            return c.JSON(http.StatusInternalServerError, map[string]string{"error": "Failed to generate token"})
        }

        return c.JSON(http.StatusOK, map[string]string{"token": tokenString})
    })

    // Protected routes with JWT middleware
    protected := e.Group("/secure", middleware.JWT([]byte("my-secret")))
    protected.GET("/profile", func(c echo.Context) error {
        user := c.Get("user").(*jwt.Token)
        claims := user.Claims.(jwt.MapClaims)
        username := claims["username"].(string)
        return c.JSON(http.StatusOK, map[string]string{"message": "Hello, " + username})
    })

    e.Logger.Fatal(e.Start(":8080"))
}

// Steps to test:
// 1. Get token: curl -X POST -H "Content-Type: application/json" -d '{"username":"user","password":"pass"}' http://localhost:8080/login
// Expected output: {"token":"eyJhbG..."}
// 2. Access protected route: curl -H "Authorization: Bearer <token>" http://localhost:8080/secure/profile
// Expected output: {"message":"Hello, user"}
Enter fullscreen mode Exit fullscreen mode

Security Considerations

Aspect Recommendation
Secret Key Store in environment variables, not code
Token Expiry Set short expiration (e.g., 1 hour)
HTTPS Mandatory to prevent token interception

Tip: Use the jwt-go library for advanced JWT features like custom claims.

Keeping Sessions Alive: Refresh Tokens

JWTs typically have short expiration times for security, but users don’t want to log in every hour. Refresh tokens solve this by issuing a long-lived token to generate new access tokens.

How It Works

  • Issue an access token (short-lived) and a refresh token (long-lived) on login.
  • Store refresh tokens securely (e.g., in a database).
  • Use the refresh token to issue new access tokens.

Here’s an example with refresh token support:

package main

import (
    "net/http"
    "time"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/golang-jwt/jwt/v4"
)

var refreshTokens = make(map[string]string) // Simulated DB for demo

func main() {
    e := echo.New()

    // Login route
    e.POST("/login", func(c echo.Context) error {
        type Login struct {
            Username string `json:"username"`
            Password string `json:"password"`
        }
        var login Login
        if err := c.Bind(&login); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid input"})
        }

        if login.Username != "user" || login.Password != "pass" {
            return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"})
        }

        // Access token
        accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
            "username": login.Username,
            "exp":      time.Now().Add(time.Minute * 15).Unix(),
        })
        accessTokenString, _ := accessToken.SignedString([]byte("access-secret"))

        // Refresh token
        refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
            "username": login.Username,
            "exp":      time.Now().Add(time.Hour * 24 * 7).Unix(),
        })
        refreshTokenString, _ := refreshToken.SignedString([]byte("refresh-secret"))

        // Store refresh token (in production, use a database)
        refreshTokens[login.Username] = refreshTokenString

        return c.JSON(http.StatusOK, map[string]string{
            "access_token":  accessTokenString,
            "refresh_token": refreshTokenString,
        })
    })

    // Refresh token route
    e.POST("/refresh", func(c echo.Context) error {
        type Refresh struct {
            RefreshToken string `json:"refresh_token"`
        }
        var refresh Refresh
        if err := c.Bind(&refresh); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid input"})
        }

        token, err := jwt.Parse(refresh.RefreshToken, func(token *jwt.Token) (interface{}, error) {
            return []byte("refresh-secret"), nil
        })
        if err != nil || !token.Valid {
            return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid refresh token"})
        }

        claims := token.Claims.(jwt.MapClaims)
        username := claims["username"].(string)

        if storedToken, exists := refreshTokens[username]; !exists || storedToken != refresh.RefreshToken {
            return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid refresh token"})
        }

        // Issue new access token
        newAccessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
            "username": username,
            "exp":      time.Now().Add(time.Minute * 15).Unix(),
        })
        newAccessTokenString, _ := newAccessToken.SignedString([]byte("access-secret"))

        return c.JSON(http.StatusOK, map[string]string{"access_token": newAccessTokenString})
    })

    e.Logger.Fatal(e.Start(":8080"))
}

// Test:
// 1. Login: curl -X POST -H "Content-Type: application/json" -d '{"username":"user","password":"pass"}' http://localhost:8080/login
// Output: {"access_token":"eyJhbG...","refresh_token":"eyJhbG..."}
// 2. Refresh: curl -X POST -H "Content-Type: application/json" -d '{"refresh_token":"eyJhbG..."}' http://localhost:8080/refresh
// Output: {"access_token":"eyJhbG..."}
Enter fullscreen mode Exit fullscreen mode

Best Practices

  • Store Refresh Tokens Securely: Use a database, not an in-memory map.
  • Rotate Tokens: Invalidate old refresh tokens on use to prevent replay attacks.
  • Short-Lived Access Tokens: Keep access tokens short (e.g., 15 minutes) for security.

Tip: Check out Auth0’s guide on refresh tokens for deeper insights.

Going Pro: Role-Based Access Control (RBAC)

For apps with multiple user types (e.g., admin vs. user), role-based access control (RBAC) ensures users only access what they’re allowed to. We’ll extend the JWT example to include roles and restrict routes based on them.

How RBAC Works

  • Add a role claim to the JWT.
  • Create middleware to check the role before allowing access.
  • Apply to specific routes or groups.

Here’s an example:

package main

import (
    "net/http"
    "time"
    "github.com/labstack/echo/v4"
    "github.com/labstack/echo/v4/middleware"
    "github.com/golang-jwt/jwt/v4"
)

func main() {
    e := echo.New()

    // Login route with roles
    e.POST("/login", func(c echo.Context) error {
        type Login struct {
            Username string `json:"username"`
            Password string `json:"password"`
        }
        var login Login
        if err := c.Bind(&login); err != nil {
            return c.JSON(http.StatusBadRequest, map[string]string{"error": "Invalid input"})
        }

        role := "user"
        if login.Username == "admin" && login.Password == "adminpass" {
            role = "admin"
        } else if login.Username != "user" || login.Password != "pass" {
            return c.JSON(http.StatusUnauthorized, map[string]string{"error": "Invalid credentials"})
        }

        token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
            "username": login.Username,
            "role":     role,
            "exp":      time.Now().Add(time.Hour * 1).Unix(),
        })
        tokenString, _ := token.SignedString([]byte("my-secret"))

        return c.JSON(http.StatusOK, map[string]string{"token": tokenString})
    })

    // Role-based middleware
    adminOnly := func(next echo.HandlerFunc) echo.HandlerFunc {
        return func(c echo.Context) error {
            user := c.Get("user").(*jwt.Token)
            claims := user.Claims.(jwt.MapClaims)
            if claims["role"] != "admin" {
                return c.JSON(http.StatusForbidden, map[string]string{"error": "Admin access required"})
            }
            return next(c)
        }
    }

    // Protected routes
    secure := e.Group("/secure", middleware.JWT([]byte("my-secret")))
    secure.GET("/user", func(c echo.Context) error {
        return c.JSON(http.StatusOK, map[string]string{"message": "User-level access"})
    })

    // Admin-only routes
    admin := secure.Group("/admin", adminOnly)
    admin.GET("/settings", func(c echo.Context) error {
        return c.JSON(http.StatusOK, map[string]string{"message": "Admin settings access"})
    })

    e.Logger.Fatal(e.Start(":8080"))
}

// Test:
// 1. Login as user: curl -X POST -H "Content-Type: application/json" -d '{"username":"user","password":"pass"}' http://localhost:8080/login
// 2. Access user route: curl -H "Authorization: Bearer <user-token>" http://localhost:8080/secure/user
// Output: {"message":"User-level access"}
// 3. Try admin route: curl -H "Authorization: Bearer <user-token>" http://localhost:8080/secure/admin/settings
// Output: {"error":"Admin access required"}
// 4. Login as admin: curl -X POST -H "Content-Type: application/json" -d '{"username":"admin","password":"adminpass"}' http://localhost:8080/login
// 5. Access admin route: curl -H "Authorization: Bearer <admin-token>" http://localhost:8080/secure/admin/settings
// Output: {"message":"Admin settings access"}
Enter fullscreen mode Exit fullscreen mode

RBAC Tips

  • Granular Roles: Define roles like editor, viewer, or superadmin for fine-grained control.
  • Database Integration: Store roles in a database and fetch them during token generation.
  • Audit Access: Log unauthorized attempts for security monitoring.

What’s Next: Building Secure Echo Apps

Authentication in Echo is flexible and powerful, letting you start simple with basic auth and scale to sophisticated systems with JWT and RBAC. Here’s a quick recap of what we covered:

  • Basic Auth: Great for quick prototypes but requires HTTPS.
  • Custom Middleware: Perfect for custom checks like API keys.
  • JWT: Ideal for stateless, scalable APIs.
  • Refresh Tokens: Keep users logged in securely.
  • RBAC: Control access based on user roles.

To take your auth game further:

  • Integrate a database like PostgreSQL for user management.
  • Use secure password hashing with bcrypt.
  • Explore OAuth2 for third-party auth (e.g., Google, GitHub).
  • Monitor and log all auth attempts for security.

Each example here is ready to run, so copy-paste, tweak, and experiment. Echo’s simplicity makes it a joy to work with, and with these auth techniques, you’re well-equipped to build secure, scalable APIs. Got questions or cool auth tricks? Share them in the comments!

Top comments (0)