Let's Go Further Appendices › Authentication with JSON Web Tokens
Previous · Contents · Next
Chapter 21.3.

Authentication with JSON Web Tokens

In this appendix we’re going to switch the authentication process for our API to use JSON Web Tokens (JWTs).

As we briefly explained earlier in the book, JWTs are a type of stateless token. They contain a set of claims which are signed (using either a symmetric or asymmetric signing algorithm) and then encoded using base64. In our case, we’ll use JWTs to carry a subject claim containing the ID of an authenticated user.

There are a few different packages available which make working with JWTs relatively simple in Go. In most cases the pascaldekloe/jwt package is a good choice — it has a clear and simple API, and is designed to avoid a couple of the major JWT security vulnerabilities by default.

If you want to follow along, please install it like so:

$ go get github.com/pascaldekloe/jwt@v1

One of the first things that you need to consider when using JWTs is the choice of signing algorithm.

If your JWT is going to be consumed by a different application to the one that created it, you should normally use an asymmetric-key algorithm like ECDSA or RSA. The ‘creating’ application uses its private key to sign the JWT, and the ‘consuming’ application uses the corresponding public key to verify the signature.

Whereas if your JWT is going to be consumed by the same application that created it, then the appropriate choice is a (simpler and faster) symmetric-key algorithm like HMAC-SHA256 with a random secret key. This is what we’ll use for our API.

So, to get authentication with JWTs working, you’ll first need to add a secret key for signing the JWTs to your .envrc file. For example:

File: .envrc
export GREENLIGHT_DB_DSN=postgres://greenlight:pa55word@localhost/greenlight
export JWT_SECRET=pei3einoh0Beem6uM6Ungohn2heiv5lah1ael4joopie5JaigeikoozaoTew2Eh6

And then you’ll need to update your Makefile to pass in the secret key as a command-line flag when starting the application, like so:

File: Makefile
...

# ==================================================================================== #
# DEVELOPMENT
# ==================================================================================== #

## run/api: run the cmd/api application
.PHONY: run/api
run/api:
	@go run ./cmd/api -db-dsn=${GREENLIGHT_DB_DSN} -jwt-secret=${JWT_SECRET}

...

Next you’ll need to edit the cmd/api/main.go file so that it parses the JWT secret from the command-line flag into the config struct:

File: cmd/api/main.go
package main

...

type config struct {
    port int
    env  string
    db   struct {
        dsn          string
        maxOpenConns int
        maxIdleConns int
        maxIdleTime  time.Duration
    }
    limiter struct {
        enabled bool
        rps     float64
        burst   int
    }
    smtp struct {
        host     string
        port     int
        username string
        password string
        sender   string
    }
    cors struct {
        trustedOrigins []string
    }
    jwt struct {
        secret string // Add a new field to store the JWT signing secret.
    }
}

...

func main() {
    var cfg config

    ...

    // Parse the JWT signing secret from the command-line flag. Notice that we leave the
    // default value as the empty string if no flag is provided.
    flag.StringVar(&cfg.jwt.secret, "jwt-secret", "", "JWT secret")

    displayVersion := flag.Bool("version", false, "Display version and exit")

    flag.Parse()

    ...
}

...

And once that’s done, you can change the createAuthenticationTokenHandler() so that it generates and sends a JWT instead of a stateful token. Like so:

File: cmd/api/tokens.go
package main

import (
    "errors"
    "net/http"
    "strconv" // New import
    "time"

    "greenlight.alexedwards.net/internal/data"
    "greenlight.alexedwards.net/internal/validator"

    "github.com/pascaldekloe/jwt" // New import
)

func (app *application) createAuthenticationTokenHandler(w http.ResponseWriter, r *http.Request) {
    var input struct {
        Email    string `json:"email"`
        Password string `json:"password"`
    }

    err := app.readJSON(w, r, &input)
    if err != nil {
        app.badRequestResponse(w, r, err)
        return
    }

    v := validator.New()

    data.ValidateEmail(v, input.Email)
    data.ValidatePasswordPlaintext(v, input.Password)

    if !v.Valid() {
        app.failedValidationResponse(w, r, v.Errors)
        return
    }

    user, err := app.models.Users.GetByEmail(input.Email)
    if err != nil {
        switch {
        case errors.Is(err, data.ErrRecordNotFound):
            app.invalidCredentialsResponse(w, r)
        default:
            app.serverErrorResponse(w, r, err)
        }
        return
    }

    match, err := user.Password.Matches(input.Password)
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    if !match {
        app.invalidCredentialsResponse(w, r)
        return
    }

    // Create a JWT claims struct containing the user ID as the subject, with an issued
    // time of now and validity window of the next 24 hours. We also set the issuer and
    // audience to a unique identifier for our application.
    var claims jwt.Claims
    claims.Subject = strconv.FormatInt(user.ID, 10)
    claims.Issued = jwt.NewNumericTime(time.Now())
    claims.NotBefore = jwt.NewNumericTime(time.Now())
    claims.Expires = jwt.NewNumericTime(time.Now().Add(24 * time.Hour))
    claims.Issuer = "greenlight.alexedwards.net"
    claims.Audiences = []string{"greenlight.alexedwards.net"}

    // Sign the JWT claims using the HMAC-SHA256 algorithm and the secret key from the
    // application config. This returns a []byte slice containing the JWT as a base64-
    // encoded string.
    jwtBytes, err := claims.HMACSign(jwt.HS256, []byte(app.config.jwt.secret))
    if err != nil {
        app.serverErrorResponse(w, r, err)
        return
    }

    // Convert the []byte slice to a string and return it in a JSON response.
    err = app.writeJSON(w, http.StatusCreated, envelope{"authentication_token": string(jwtBytes)}, nil)
    if err != nil {
        app.serverErrorResponse(w, r, err)
    }
}

...

Go ahead and vendor the new github.com/pascaldekloe/jwt dependency and run the API like so:

$ make vendor
$ make run/api

Then when you make a request to the POST /v1/tokens/authentication endpoint with a valid email address and password, you should now get a response containing a JWT like this (line breaks added for readability):

$ curl -X POST -d '{"email": "faith@example.com", "password": "pa55word"}' localhost:4000/v1/tokens/authentication
{
    "authentication_token": "eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJncmVlbmxpZ2h0LmFsZXhlZHdhcm
    RzLm5ldCIsInN1YiI6IjciLCJhdWQiOlsiZ3JlZW5saWdodC5hbGV4ZWR3YXJkcy5uZXQiXSwiZXhwIjoxNj
    E4OTM4MjY0LjgxOTIwNSwibmJmIjoxNjE4ODUxODY0LjgxOTIwNSwiaWF0IjoxNjE4ODUxODY0LjgxOTIwND
    h9.zNK1bJPl5rlr_YvjyOXuimJwaC3KgPqmW2M1u5RvgeA"
}

If you’re curious, you can decode the base64-encoded JWT data. You should see that the content of the claims matches the information that you would expect, similar to this:

{"alg":"HS256"}{"iss":"greenlight.alexedwards.net","sub":"7","aud":["greenlight.alexedwards.net"],
"exp":1618938264.819205,"nbf":1618851864.819205,"iat":1618851864.8192048}...

Next you’ll need to update the authenticate() middleware to accept JWTs in an Authorization: Bearer <jwt> header, verify the JWT, and extract the user ID from the subject claim.

When we say “verify the JWT”, what we actually mean is the following four things:

Go ahead and update the authenticate() middleware as follows:

File: cmd/api/middleware.go
package main

import (
    "errors"
    "expvar"
    "fmt"
    "net/http"
    "strconv"
    "strings"
    "sync"
    "time"

    "greenlight.alexedwards.net/internal/data"

    "github.com/pascaldekloe/jwt" // New import
    "github.com/tomasen/realip"
    "golang.org/x/time/rate"
)

...

func (app *application) authenticate(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        w.Header().Add("Vary", "Authorization")

        authorizationHeader := r.Header.Get("Authorization")

        if authorizationHeader == "" {
            r = app.contextSetUser(r, data.AnonymousUser)
            next.ServeHTTP(w, r)
            return
        }

        headerParts := strings.Split(authorizationHeader, " ")
        if len(headerParts) != 2 || headerParts[0] != "Bearer" {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        token := headerParts[1]

        // Parse the JWT and extract the claims. This will return an error if the JWT 
        // content doesn't match the signature (i.e. the token has been tampered with)
        // or the algorithm isn't valid.
        claims, err := jwt.HMACCheck([]byte(token), []byte(app.config.jwt.secret))
        if err != nil {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        // Check if the JWT is still valid at this moment in time.
        if !claims.Valid(time.Now()) {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        // Check that the issuer is our application.
        if claims.Issuer != "greenlight.alexedwards.net" {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        // Check that our application is in the expected audiences for the JWT.
        if !claims.AcceptAudience("greenlight.alexedwards.net") {
            app.invalidAuthenticationTokenResponse(w, r)
            return
        }

        // At this point, we know that the JWT is all OK and we can trust the data in 
        // it. We extract the user ID from the claims subject and convert it from a 
        // string into an int64.
        userID, err := strconv.ParseInt(claims.Subject, 10, 64)
        if err != nil {
            app.serverErrorResponse(w, r, err)
            return
        }

        // Lookup the user record from the database.
        user, err := app.models.Users.Get(userID)
        if err != nil {
            switch {
            case errors.Is(err, data.ErrRecordNotFound):
                app.invalidAuthenticationTokenResponse(w, r)
            default:
                app.serverErrorResponse(w, r, err)
            }
            return
        }

        // Add the user record to the request context and continue as normal.
        r = app.contextSetUser(r, user)

        next.ServeHTTP(w, r)
    })
}

Lastly, to get this working, you’ll need to create a new UserModel.Get() method to retrieve the user details from the database based on their ID.

File: internal/data/users.go
package data

...

func (m UserModel) Get(id int64) (*User, error) {
    query := `
        SELECT id, created_at, name, email, password_hash, activated, version
        FROM users
        WHERE id = $1`

    var user User

    ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
    defer cancel()

    err := m.DB.QueryRowContext(ctx, query, id).Scan(
        &user.ID,
        &user.CreatedAt,
        &user.Name,
        &user.Email,
        &user.Password.hash,
        &user.Activated,
        &user.Version,
    )

    if err != nil {
        switch {
        case errors.Is(err, sql.ErrNoRows):
            return nil, ErrRecordNotFound
        default:
            return nil, err
        }
    }

    return &user, nil
}

You should now be able to make a request to one of the protected endpoints, and it will only succeed if your request contains a valid JWT in the Authorization header. For example:

$ curl -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJncmVlbmxpZ..." localhost:4000/v1/movies/2
{
    "movie": {
        "id": 2,
        "title": "Black Panther",
        "year": 2018,
        "runtime": "134 mins",
        "genres": [
            "sci-fi",
            "action",
            "adventure"
        ],
        "version": 2
    }
}

$ curl -H "Authorization: Bearer INVALID" localhost:4000/v1/movies/2
{
    "error": "invalid or missing authentication token"
}

If you’re planning to put a system into production which uses JWTs, I also recommend spending some time to read and fully understand the following two articles: