The JSON Web Token (JWT) provides a means of authenticating users in a stateless manner, eliminating the need to store user information on the system itself, in contrast to session-based authentication.
Upon a user's successful login, such as "user1," they receive a token like the following:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ.2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54
This JWT consists of three parts, separated by periods:
-
Header (Base64-encoded):
- Example:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
- Specifies information such as the algorithm for signature generation. This part remains consistent for any JWT using the same algorithm.
- Example:
-
Payload (Base64-encoded):
- Example:
eyJ1c2VybmFtZSI6InVzZXIxIiwiZXhwIjoxNTQ3OTc0MDgyfQ
- Contains application-specific details, such as the username, along with information about token expiry and validity.
- Example:
-
Signature:
- Example:
2Ye5_w1z3zpD4dSGdRp3s98ZipCNQqmsHRB9vioOx54
- Generated by hashing the combined base64 representations of the header and payload, along with a secret key. Note that the header and payload are base64-encoded, not encrypted, allowing anyone to decode them using a base64 decoder.
- Example:
-
Header Decoding:
{ "alg": "HS256", "typ": "JWT" }
Example Command:
echo eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 | base64 -d
-
Payload Decoding:
{ "username": "user1", "exp": 1547974082 }
The security of a JWT lies in how the third part (signature) is generated. When issuing a token, the application creates the header and payload, and combines their base64 representations with a secret key. This concatenated value is then passed through a hashing algorithm, specified in the header (e.g., HS256), ensuring the integrity and authenticity of the token. The secret key is known only to the application, providing a secure means of token validation.
Secret creation generally uses hashing and is largely irreversible and hence the secret key remains, well, a secret.
To verify a JWT, the server follows a process of regenerating the signature using the header and payload extracted from the incoming JWT, along with its secret key. If the newly generated signature matches the one present in the JWT, the JWT is deemed valid.
In the context of someone attempting to forge a token, while it's feasible to generate the header and payload, lacking knowledge of the key renders it impossible to produce a valid signature. Even if an individual tries to manipulate the existing payload of a legitimate JWT, the signatures will cease to match.
This mechanism ensures that a JWT serves as a secure means of authorizing users without the necessity of storing substantial information (except the key) on the issuing server. The process of verifying the signature safeguards the integrity and authenticity of the token, making it a reliable method for secure user authorization.
Note: Start by clicking on
Provision
. Once that's done, click onAccess
. When you see VS Code, launch aTerminal
.
To run this application, build the docker image:
cd jwt-showcase
docker build -t jwt-showcase .
While that image is building, let's discuss what's happening in this application.
The interesting bits of this application can be found at: /root/go-jwt/handlers.go
This implementation uses github.com/golang-jwt/jwt/v4
library. Let's break down the main components and functionalities:
-
JWT Key and User Database:
var jwtKey = []byte("thisisasecrettrustme") var users = map[string]string{ "user1": "password1", "user2": "password2", }
jwtKey
: A secret key used for signing and verifying JWTs.users
: A simple in-memory user database with usernames as keys and passwords as values.
Note: In production environments, passwords are to be salted, hashed, and stored in appropriate databases.
-
Credentials and Claims Structs:
type Credentials struct { Username string `json:"username"` Password string `json:"password"` } type Claims struct { Username string `json:"username"` jwt.RegisteredClaims }
Credentials
: Struct to model user credentials during login.Claims
: Struct to model JWT claims, including the username and standard registered claims.
Claims are typically used to transmit information such as the identity of the user, the permissions they have, and additional metadata about the token. JWTs consist of three parts: a header, a payload, and a signature. The payload contains claims.
-
Signin Handler:
If a user logs in with the correct credentials, this handler will then set a cookie on the client side with the JWT value. Once a cookie is set on a client, it is sent along with every request henceforth. Now we can write our welcome handler to handle user specific information.
func Signin(w http.ResponseWriter, r *http.Request){ ... // Get the JSON body and decode into credentials err := json.NewDecoder(r.Body).Decode(&creds) ... // Get the expected password from our in memory map expectedPassword, ok := users[creds.Username] ... // Create the JWT claims, which includes the username and expiry time claims := &Claims{ Username: creds.Username, RegisteredClaims: jwt.RegisteredClaims{ // In JWT, the expiry time is expressed as unix milliseconds ExpiresAt: jwt.NewNumericDate(expirationTime), }, ... // Finally, we set the client cookie for "token" as the JWT we just generated // we also set an expiry time which is the same as the token itself http.SetCookie(w, &http.Cookie{ Name: "token", Value: tokenString, Expires: expirationTime, ... }
- Handles user sign-in.
- Validates user credentials against the in-memory user database.
- If valid, generates a JWT and sets it as a cookie.
-
Welcome Handler:
Now that all logged in clients have session information stored on their end as cookies, we can use it to:
- Authenticate subsequent user requests
- Get information about the user making the request
Let's write our
Welcome
handler to do just that:func Welcome(w http.ResponseWriter, r *http.Request){ ... // Parse the JWT string and store the result in `claims`. tkn, err := jwt.ParseWithClaims(tknStr, claims, func(token *jwt.Token) (interface{}, error) { return jwtKey, nil }) ... // Finally, return the welcome message to the user, along with their // username given in the token w.Write([]byte(fmt.Sprintf("Hello there %s!", claims.Username))) ... }
- Verifies JWT from the cookie and responds with a welcome message including the username.
-
Refresh Handler:
In this illustration, a brief expiration time of five minutes has been established. It's impractical to anticipate users logging in every five minutes as their token expires.
To address this, we'll implement an additional route, /refresh, which accepts the existing token (still valid) and issues a new token with an extended expiration time.
To mitigate potential misuse of a JWT, it's common practice to maintain a relatively short expiration time, often in the order of a few minutes. The typical approach involves the client application refreshing the token in the background, ensuring a seamless and secure user experience.
func Refresh(w http.ResponseWriter, r *http.Request){ ... // We ensure that a new token is not issued until enough time has elapsed // In this case, a new token will only be issued if the old token is within // 30 seconds of expiry. Otherwise, return a bad request status if time.Until(claims.ExpiresAt.Time) > 30*time.Second { w.WriteHeader(http.StatusBadRequest) return } ... // Now, create a new token for the current use, with a renewed expiration time expirationTime := time.Now().Add(5 * time.Minute) claims.ExpiresAt = jwt.NewNumericDate(expirationTime) token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) tokenString, err := token.SignedString(jwtKey) ... // Set the new token as the users `token` cookie http.SetCookie(w, &http.Cookie{ Name: "token", Value: tokenString, Expires: expirationTime, }) ... }
- Refreshes the JWT by issuing a new token if the existing token is within 30 seconds of expiration.
-
Logout Handler:
Logging out in JWT-based authentication can be challenging due to the stateless nature of our application, meaning we don't retain any information about issued JWT tokens on our server. Our sole knowledge consists of the secret key and the encoding/decoding algorithm used for JWTs. In our application, a token is considered valid if it meets these criteria.
To address logout, the recommended approach is to issue tokens with a short expiration time. We then prompt the client to regularly refresh the token. This strategy ensures that, for a given expiration period T, the maximum duration a user can remain logged in without explicit permission from the application is T seconds. By adopting this method, we maintain security while working within the stateless architecture of our application.
func Logout(w http.ResponseWriter, r *http.Request){ ... // immediately clear the token cookie http.SetCookie(w, &http.Cookie{ Name: "token", Expires: time.Now(), }) ... }
- Clears the JWT cookie for logout.
In short, this example application provides a basic authentication system using JWTs, allowing sign-in, token issuance, token refresh, and logout functionalities.
Some caveats to this demonstration include too simple an in-memory user database and a static secret key, which is simply unacceptable for production.
Additionally, securing sensitive information like the JWT key in a secure store like a vault and automatically rotating is crucial in a real-world scenario.
Then run the docker image:
docker run -p 8080:8080 jwt-showcase
Now, using any HTTP client with support for cookies (like Postman, or your web browser) make a sign-in request with the appropriate credentials:
If you are using HTTPie, you can use the
--session
flag to persist cookies between requests.
Open a new terminal, and run the following command:
Remember to replace
localhost
with the IP address of the server if you're using an external application like Postman.
You can find the IP address of the lab instance using the commandserverip
http --session=user1 POST http://localhost:8000/signin username=user1 password=password1
You can now try hitting the welcome route from the same client to get the welcome message:
http --session=user1 GET http://localhost:8080/welcome Cookie:token=$token
Hit the refresh route, and then inspect the clients cookies to see the new value of the token cookie:
http --session=user1 POST http://localhost:8080/refresh Cookie:token=$token
To log out:
http --session=user1 POST http://localhost:8080/logout