
I came across this line of code in an authentication tutorial but didn’t fully understand what it was doing under the hood, and the tutorial didn’t explain it either. So, I researched how it actually works internally and documented my findings here in the simplest way possible.
Firstly:
func ValidateToken(tokenString string)(*Claims , error)
- Takes a JWT string called tokenString
- Returns a pointer to a Claims struct containing the decoded payload if valid
- Returns an error if the token is invalid
my Claims Struct is as follow:
type Claims struct {
UserID string json:"user_id"
jwt.StandardClaims
}
Secondly:
secretKey := GetJWTKey()
- Fetches the global secret key used for signing and validating JWTs
- This is the same key for all users in my case since it is HS256
- Stored as []byte internally
Thirdly (This is the part that I had problem understanding)
jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return secretKey, nil
})
Just a couple of things to understand: [
a. &Claims{} -> so Claims{} creates a new empty Claims struct first and we pass in the pointer to this created Claims struct. If we pass in just Claims{} (value), Go would copy the struct and fill in the token details in that copied struct instead of the original struct so the original struct remains unchanged
b. interface{} here is referring to generic type which can hold []byte (HS256) or RSA keys (RS256) etc...
First Step:
When jwt.ParseWithClaims function gets called firstly it does:
-> Splits the token string into three parts
-> header , payload , signature
Header:
-> Encoded in Base64
-> Contains metadata about the token, usually:
{
"alg": "HS256", // algorithm used to sign the token
"typ": "JWT" // type of token
}
alg → tells the server how to verify the signature (HMAC SHA256, RSA, etc.)
typ → usually "JWT", indicates the type of token
-> information used to verify the signature.
Payload (Claims):
-> Also encoded in Base64
-> Contains the data about the user and token
-> Payload in my case here corresponds to the Claims structure I defined above
-> An example payload JSON:
{
"user_id": "12345",
"exp": 1712345678, // expiration timestamp
"iat": 1712342000, // issued at timestamp
"iss": "my-app", // issuer
"sub": "12345" // subject (optional)
}
and this payload is what gets parsed into &Claims{} struct
Signature
-> Calculated as:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secretKey
)
Purpose: ensures token hasn’t been tampered with
-> Only valid if the server knows the secretKey
Second Step:
Decodes header and payload into a temporary jwt.Token struct:
type Token struct {
Raw string
Method SigningMethod
Header map[string]interface{} -> alg types
Claims Claims
Signature string
Valid bool
}
Third Step:
Call the function:
func(token *jwt.Token)(interface{}, error) {return secretKey,nil}
Here, we pass in the temporary jwt.Token struct formed in Step 2 into the function
-> Since this jwt.Token contains the header, we can dynamically choose which key to choose based on the method, but in my case as mentioned earlier I am using the same secretKey all the time so no dynamic selection is needed
-> Return the secretKey
Fourth Step:
After the key function returns secretKey, jwt.ParseWithClaims does the following internally:
-> Recalculate the expected signature:
a. Uses the decoded + + secretKey
b. Algorithm is determined by token.Method from the header(HS256, etc.)
-> Compare with the signature in the token:
type Token struct {
Raw string
Method SigningMethod
Header map[string]interface{} -> alg types
Claims Claims
**Signature string** <- Compare with this
Valid bool
}
a. If they match → token is valid and untampered
b. If they don’t → token is invalid
As mentioned above:
Signature
-> Calculated as:
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secretKey
)
-> Fill the Claims struct (&Claims{}):
a. Decoded payload is written into your Claims struct pointer
b. Standard claims (exp, iat, iss, etc.) and custom claims (user_id) are all set
-> token.Valid is set to true if signature matches and standard claims (e.g., expiration) are valid
Fifth Step:
After jwt.ParseWithClaims returns
if claims, ok := token.Claims.(*Claims); ok && token.Valid {
return claims, nil
}
-> We type assert the generic token.Claims to my concrete *Claims struct
-> If type assertion succeeds then return the value (claims of type *Claims) and ok (type boolean true)
-> Then we can safely access:
claims.UserID
claims.ExpiresAt
claims.Issuer
TLDR Flow:
- jwt.ParseWithClaims receives tokenString
- Splits token → header, payload, signature
- Decodes header + payload → temporary jwt.Token struct
- Calls key function with jwt.Token → returns secretKey
- Recalculates signature using secretKey
- Compares with token’s signature
- Match → valid
- No match → invalid
- Fills &Claims{} struct with payload data
- Returns token with token.Valid = true
Top comments (0)