How to create a basic JWT library in Go
I’ve used JWT for authentication in a few projects, so far, but I’ve never really looked into how they work under the hood. Lately, though, I became curious, looked it up and it’s really not that hard or complicated. Let me walk you through how it works. I’ll use Go, because I’m currently learning the language and the more I use it the more I like it. Besides, Go is easy to read and thus easy to pick up.
What are JSON Web Tokens?
Right off the bat, unless a token is unsigned (just the header + . + payload, no . after the payload and no signature) your JWT is technically a “JSON Web Signature” (JWS). But we’re not be that picky here, you can take JWT as a hyperonym of JWS (or JWS as a hyponym or JWT) and you wouldn’t be wrong, so let’s just talk about JWTs, even though we’re technically speaking of JWSs.
JSON Web Tokens are basically just strings that consist of three parts – the header, payload and signature – divided by simple . (dot) characters. Let’s go through each of the three parts and see what they are and what each part is good for.
1. The Header
The header contains information about the token itself. Most of the time it will just look like this:
{
"typ": "JWT",
"alg": "HS256"
}
Keys are mostly three-letters long, to save some bytes, because base64-encoding is wasteful enough as it is, but I digress.
What do these three-letter JSON-keys describe?
typwill always beJWTalgdenotes the algorithm used to create the signature (see below)
But there is one more that you’ll rarely see, cty, which stands for “Content Type”. This one works a bit like the HTTP header of the same name, in that is describes what the JWT contains, but you’ll only never need this, if you have a JWT inception thing going on, meaning, when your JWT contains another JWT, and unless it actually does, you can safely ignore this key altogether.
What is base64-encoding and why are JWT parts encoded in base64?
Tokens are often used in URLs and thus it makes sense that they should be URL safe. Pure base64 is not URL safe per se, because it can contain + and /, which have different meanings in URLs. However, base64url is URL safe, because + is replaced with - (dash) and / with _ (underscore) which is why JWT parts are supposed to be encoded in Base64url and not plain base64 format.
Base64 is way to represent binary data with 65 UTF-8 characters. “65 characters?”, I hear you object, “Shouldn’t there be 64? It’s base64, after all!” Good catch, however 65 is indeed correct, because the 65th character (=) (equals) is for padding only and doesn’t actually represent any data, but rather its absence. Sounds confusing at first, but imagine you were streaming data in base64. Streaming essentially means emitting data in chunks and reassembling it on the receiving end. Base64 encoded data is divided into chunks of 6 bits, each of which gets mapped to one of the 64 avaiable characters, but ASCII (and thus UTF-8, since it’s designed to be backwards compatible with ASCII) is an 8-bit encoding. That means without padding, your browser would have a hard time knowing where one character ends and another one starts.
The 64 characters used for base64-encoding are A–Z, a–z, 0–9, +, / and = whereby the latter does not represent a real character, bis merely acts as a paddign character for when the last part of the encoding string does not represent a full 6-bit block.
In the token, the header JSON is base64Url-encoded. That’s almost the same as normal base64-encoding with the only differences that three specific characters are either omitted or replaced by URL-safe characters:
- the padding (
=) is omitted, because the header, payload and signature of the token are divided by the dot (.) and thus don’t need to have the same length to differentiate between them. +is replaced with-/is replaced with_
2. Payload
3. Signature
package jwt
import (
"encoding/json"
"errors"
"fmt"
"strings"
"time"
)
var InvalidToken = errors.New("invalid token")
// JWT Header
type Header struct {
Alg string `json:"alg"`
Typ string `json:"typ"`
}
// JWT Claims
type Claims struct {
Sub string `json:"sub"`
Iat int64 `json:"iat"`
Exp int64 `json:"exp"`
}
func NewClaims(sub string) Claims {
return Claims{
Sub: sub,
Exp: time.Now().Add(5 * time.Hour).Unix(),
Iat: time.Now().Unix(),
}
}
func Generate(claims Claims, secret string) (string, error) {
// Create the header JSON
header, err := Serialize(Header{
Typ: "JWT",
Alg: "HS256",
})
if err != nil {
fmt.Println(err)
return "", err
}
// Create the payload JSON
payload, err := Serialize(claims)
if err != nil {
fmt.Println(err)
return "", err
}
// Combine base64 header and payload
headerAndPayload := fmt.Sprint(base64Encode(header), ".", base64Encode(payload))
// Sign combined base64 header and claims
signature, err := generateHmac(headerAndPayload, secret)
if err != nil {
return "", err
}
// Assemble the token
token := fmt.Sprint(headerAndPayload, ".", signature)
return token, nil
}
func Verify(token, secret string) (*Claims, error) {
parts := strings.Split(token, ".")
if len(parts) < 3 {
fmt.Println(InvalidToken)
return nil, InvalidToken
}
headerAndPayload := strings.Join(parts[:2], ".")
signature := parts[2:][0]
mac, err := base64Decode(signature)
if err != nil {
fmt.Println(err)
return nil, err
}
valid, err := verifyHmac(mac, headerAndPayload, secret)
if err != nil {
fmt.Println(err)
return nil, err
}
if !valid {
fmt.Println(InvalidToken)
return nil, InvalidToken
}
encodedClaimJson := strings.Split(headerAndPayload, ".")[1:][0]
decodedClaimJson, err := base64Decode(encodedClaimJson)
if err != nil {
fmt.Println(err)
return nil, err
}
claims := &Claims{}
err = json.Unmarshal(decodedClaimJson, claims)
if err != nil {
fmt.Println(InvalidToken)
return nil, InvalidToken
}
return claims, nil
}