Protecting API access with JWT
A common use case for APIs is to provide authentication middleware, which will let a client make authorized requests to your APIs. Generally, your client performs some sort of authentication, and a session token is issued. Recently, JWT (JSON Web Tokens) are a popular method of providing a session token with an expire time, which doesn’t require some sort of storage to perform validation.
This is a continuation of a previous article. If you’re new, you should read Handling HTTP requests with go-chi first and then continue below.
We will be using go-chi/jwtauth on top of go-chi/chi to add an authentication layer to your APIs. The authentication may be optional (logged in user, logged out user) or mandatory (only logged in users). This allows you to implement split-logic on your APIs, to enrich the returned data based on the validity of a JWT parameter. I’m also using titpetric/factory/resputil to simplify error handling and formatting of JSON response data.
So what is JWT exactly?
JSON Web Token (JWT) is an open standard (RFC 7519) that defines a compact and self-contained way for securely transmitting information between parties as a JSON object. This information can be verified and trusted because it is digitally signed.
A token is constructed from three pieces of information:
- the header, specifying the signature algorithm,
- the claims, which may also include an expire time,
- the signature based on header settings
Using these three pieces, it’s possible for your API service to re-create the signature based on data in the header and in the claims pieces of the JWT. This is possible because this signature is created using a secret signing key, which is only known to the server.
jwtauth.New("HS256", []byte("K8UeMDPyb9AwFkzS"), nil)
In case your signing key is compromised, you should immediately change it. This will invalidate all existing issued JWT’s, forcing your clients to re-authenticate to your service.
Claims
JWT claims are the facility with which you state that the client using your API services might
be "user_id": "1337"
or similar. Think of it as a map[string]interface{}
, with some casting
required. When your client authenticates against your API, by performing a login with a client ID
and Secret, you issue a new JWT that doesn’t require you re-authenticating the client in the database,
until the token expires.
It’s a good thing to issue a debug token from your application and write it to the log. This way you’ll have a valid token to perform testing against your application.
type JWT struct {
tokenClaim string
tokenAuth *jwtauth.JWTAuth
}
func (JWT) new() *JWT {
jwt := &JWT{
tokenClaim: "user_id",
tokenAuth: jwtauth.New("HS256", []byte("K8UeMDPyb9AwFkzS"), nil),
}
log.Println("DEBUG JWT:", jwt.Encode("1"))
return jwt
}
func (jwt *JWT) Encode(id string) string {
claims := jwtauth.Claims{}.
Set(jwt.tokenClaim, id).
SetExpiryIn(30 * time.Second).
SetIssuedNow()
_, tokenString, _ := jwt.tokenAuth.Encode(claims)
return tokenString
}
When you’ll create a new JWT object with JWT{}.new()
, a debug token will be printed to the log.
2018/04/19 11:35:18 DEBUG JWT: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyX2lkIjoiMSJ9.ZEBtFVPPLaT1YxsNpIzVGSnM4Vo7ZrEvp77jKgfN66s
You can pass this token via URL query parameter to test GET requests, or use it in your
test suite in order to issue more complex API requests, passing it with the Authorization
header,
or as a Cookie.
Note: Be very careful that you generate your tokens with an expire time. If you don’t do this, they will be valid until you change your signing key. Another option is to invalidate individual tokens on the server side, which requires some sort of database for logging and revoking them. For example: instead of issuing actual user IDs as the example shows, you’d create a session ID, which you can additionally validate for expiry/logout.
The example already sets the needed parameters in order so you’ll validate tokens with an expiry time. If you issue requests to a protected endpoint after the token expires, an error message is returned, hinting you to re-authenticate on some API call.
Protecting API access with JWT
Each request that comes to the API can include a JWT Verifier. This works similarly to CORS headers - it tests the presence of a JWT in either the HTTP query string, cookie or Authorization HTTP header. The result of the verifier are new context variables for the JWT and a possible parsing errors. The verifier doesn’t break out of the request in case of a missing or invalid token, it’s job is to provide this information to the Authenticator.
The go-chi/jwtauth package provides a default verifier, which you can use out of the gate. Let’s add an utility function to our JWT struct that will return one:
func (jwt *JWT) Verifier() func(http.Handler) http.Handler {
return jwtauth.Verifier(jwt.tokenAuth)
}
We include this verifier on every request. This makes it possible to pass tokens to API endpoints which don’t explicitly require an authenticated user. In such cases, you can retrieve and handle any claims, still providing a valid response in cases where a JWT is not present.
login := JWT{}.new()
mux := chi.NewRouter()
mux.Use(cors.Handler)
mux.Use(middleware.Logger)
mux.Use(login.Verifier())
Instead of using mux.Route
as we did before, we should use mux.Group() to split requests
into authenticated and public endpoints. Using Group() allows us to add new handlers, to the existing global ones.
This way we avoid certain API call prefixes like /api/private/*
.
Group creates a new inline-Mux with a fresh middleware stack. It’s useful for a group of handlers along the same routing path that use an additional set of middleware.
// Protected API endpoints
mux.Group(func(mux chi.Router) {
// Error out on invalid/empty JWT here
mux.Use(login.Authenticator())
{
mux.Get("/time", requestTime)
mux.Route("/say", func(mux chi.Router) {
mux.Get("/{name}", requestSay)
mux.Get("/", requestSay)
})
}
})
// Public API endpoints
mux.Group(func(mux chi.Router) {
// Print info about claim
mux.Get("/api/info", func(w http.ResponseWriter, r *http.Request) {
owner := login.Decode(r)
resputil.JSON(w, owner, errors.New("Not logged in"))
})
})
The API endpoints /time
and /say
will now be accessible only with a valid token. The /time
endpoint
doesn’t explicitly handle JWT verification, but leaves that job to the Authenticator. For example,
if we issue a request against this endpoint with an expired token, we might end up with something like:
{
"error": {
"message": "Error validating JWT: jwtauth: token is expired"
}
}
But if we issue a request against /info
, we can end up with:
{
"response": "1"
}
Or with an expired token:
{
"error": {
"message": "Not logged in"
}
}
The difference in the responses is because we implemented full validation in the Decode
function.
If the JWT is invalid or expired, a custom error will be returned instead of the default one which
is implemented in Authenticate
and used in the fully protected endpoint /time
.
func (jwt *JWT) Decode(r *http.Request) string {
val, _ := jwt.Authenticate(r)
return val
}
func (jwt *JWT) Authenticate(r *http.Request) (string, error) {
token, claims, err := jwtauth.FromContext(r.Context())
if err != nil || token == nil {
return "", errors.Wrap(err, "Empty or invalid JWT")
}
if !token.Valid {
return "", errors.New("Invalid JWT")
}
return claims[jwt.tokenClaim].(string), nil
}
We use the same Authenticate function to provide the Authenticator()
middleware that enforces
JWT usage on private API endpoints. The error in Decode()
is ignored, as the called function
already enforces an empty string return. The Authenticator middleware however, returns the
error in full:
func (jwt *JWT) Authenticator() func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, err := jwt.Authenticate(r)
if err != nil {
resputil.JSON(w, err)
return
}
next.ServeHTTP(w, r)
})
}
}
The full code sample for the above JWT authenticated microservice is available on GitHub. Feel free to check it out and take it for a spin.
While I have you here...
It would be great if you buy one of my books:
- Go with Databases
- Advent of Go Microservices
- API Foundations in Go
- 12 Factor Apps with Docker and Go
Feel free to send me an email if you want to book my time for consultancy/freelance services. I'm great at APIs, Go, Docker, VueJS and scaling services, among many other things.
Want to stay up to date with new posts?
Stay up to date with new posts about Docker, Go, JavaScript and my thoughts on Technology. I post about twice per month, and notify you when I post. You can also follow me on my Twitter if you prefer.