When building an API there are a number of important decisions to be made. Authentication and security are generally high on the list of features to implement. For many scenarios, it is common to reach for a JWT-based auth workflow. It is easy to implement, most frameworks have a JWT library and let's face it, there are countless examples on the internet for implementing it. Given its popularity, JWT must be the correct approach! … or is it?
A typical web API authentication flow follows the following steps:
While appearing simple, the steps listed above take a lot for granted. There are a number of implementation details that impact the overall security of your application. For example, step 1 implies you are securely hashing the password and not storing it in plain text. Step 2 implies all communications over the wire use https. Step 3 implies you are securely storing the bearer token. Step 4 implies that the token is correctly being verified.
One of the many reasons JWT has become ubiquitous is due to the volume of how-to's/guides/etc, many of which have been cargo-culted, perpetuating its use, and occassionally are using insecure or incorrect implementations. I would argue that in many cases your application doesn't need JWT and is better off with a simpler solution.
If you are verifying the token on the server (as you likley should be), what does JWT offer that a bearer token doesn't? Below I'll outline a simple POC that relies on a bearer token auth scheme.
No self-respecting blog post offering alternatives to JWT would be complete without a tptacek quote from Hacker News. The following post from 2017 is the driving force behind this example:
"For almost every use I've seen in the real world, JWT is drastic overkill; often it's just an gussied-up means of expressing a trivial bearer token, the kind that could be expressed securely with virtually no risk of implementation flaws simply by hexifying 20 bytes of urandom."
This basic application provides two endpoints written in Golang: one for authenticating and one for responding to authenticated requests. It uses go-cache as the "database" for storing the users and the auth tokens purely as a means to make it self-contained.
// An application context that contains anything required by the handlers
type AppContext struct {
DB *cache.Cache
}
//Auth - Contains the auth token
type AuthContext struct {
Token string `json:"token"`
}
//User - Basic user model
type User struct {
Username string `json:"username"`
PasswordHash string `json:"-"`
}
The main function sets up a pseudo-db and adds a single user (User management is an exercise left for the reader). Two routes are also declared within the function:
/login
Handles the authentication request and returns the bearer token/user
Returns information about the user to authenticated requestsfunc main() {
// We'd use an actual database IRL
db := cache.New(cache.NoExpiration, cache.NoExpiration)
// Here we'll populate the database with a sample user
username := "nimalo"
password := []byte("Secret Password!")
hash, err := bcrypt.GenerateFromPassword(password, bcrypt.MinCost)
if err != nil {
log.Fatal(err)
}
u := User{Username: username, PasswordHash: string(hash)}
db.Set(u.Username, u, cache.NoExpiration)
a := &AppContext{
DB: db,
}
http.HandleFunc("/login", a.loginHandler)
http.HandleFunc("/user", a.userHandler)
log.Fatal(http.ListenAndServe(":8080", nil))
}
The login handler is responsible for receiving the authentication payload (username and password), authenticating it, and either returning an error OR a bearer token. It searches the database for the submitted user, and then compares the clear-text password against the hashed password using bcrypt's CompareHashAndPassword
function. If the credentials are valid, it creates a pseudo-random token and persists it.
One of the big arguments for JWT is it is stateless; however, most implementations verify server side, which is the entire crux of this post. Verifying a pseudo-random token is easier and (IMHO) no less secure than a JWT.
func (a *AppContext) loginHandler(w http.ResponseWriter, r *http.Request) {
username := r.FormValue("username")
password := r.FormValue("password")
var user User
if u, found := a.DB.Get(username); found {
user = u.(User)
}
// Compare the plain-text password with the stored hash
err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password))
// If a user isn't found OR passwords don't match return an error
if user.Username != username || err != nil {
http.Error(w, "Access Denied", http.StatusForbidden)
return
}
// Generate an auth token user CSPRNG (cryptographically secure pseudorandom number generator)
bytes := make([]byte, 128)
_, err = rand.Read(bytes)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
token := hex.EncodeToString(bytes)
auth := Auth{Token: token}
// Store the auth token and associate it with the user
// Note - Use a db or Redis for storing this IRL. A cache
// invalidation strategy will also need to be considered
a.DB.Set(auth.Token, user.Username, cache.DefaultExpiration)
js, err := json.Marshal(auth)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(js)
}
Once a client has received a token, they'll then use it for subsequent requests. How that gets implemented is an entirely seperate blog post (or book). In this example, it is looking for a bearer token in the Authorization header but it could just as easily use a cookie (or both?). If a valid token is received it returns the user name, otherwise access will be denied. This post does not dive into revocation strategies. Presumably the token will expire at some point.
func (a *AppContext) userHandler(w http.ResponseWriter, r *http.Request) {
authorization := r.Header.Get("Authorization")
idToken := strings.TrimSpace(strings.Replace(authorization, "Bearer", "", 1))
var username string
if u, found := a.DB.Get(idToken); found {
username = u.(string)
}
if username == "" {
http.Error(w, "Access Denied", http.StatusForbidden)
return
}
user := User{Username: username}
js, err := json.Marshal(user)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
w.Write(js)
}
JWT and stateless tokens do have their place in the security toolbox, particularly when multiple provider API's are communicating. For a typical web/mobile application, just use a bearer token. They are simple to implement, easily revokable, and just as secure on both the client and server (if using TLS) without the complexity of needing to correctly implement JWT. The full example file can be found here.