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!"
How It Works
- The
BasicAuth
middleware checks theAuthorization
header for Base64-encoded credentials. - If the username and password match, the request proceeds to the
/dashboard
route. - 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"}
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
- Generate a JWT on user login.
- Use Echo’s JWT middleware to validate tokens.
- 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"}
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..."}
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"}
RBAC Tips
-
Granular Roles: Define roles like
editor
,viewer
, orsuperadmin
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)