In this article
November 18, 2025
November 18, 2025

How to handle JWT in Go

Everything you need to know to implement and validate JWTs securely in Go: from signing to verifying with JWKS, with code examples and best practices throughout.

JSON Web Tokens (JWTs) are one of the most common mechanisms for representing identity, session state, and authorization in modern applications. They allow services to exchange signed, tamper-resistant information without needing traditional server-side sessions.

In Go, JWT handling is a bit lower-level compared to more batteries-included languages, which gives developers fine-grained control over token parsing, verification, and key management.

This guide walks through everything you need to know to safely consume, validate, and work with JWTs in Go, including HS256/RS256 verification, JWKS, middleware design, rotation strategies, and common pitfalls. Let’s dive right in.

JWT 101

A JSON Web Token is a compact, URL-safe string that represents a set of claims. They are typically used to:

  • Indicate authenticated user identity
  • Assert permissions or roles
  • Pass metadata between systems

A JWT consists of three Base64URL-encoded components:

  
<header>.<payload>.<signature>
  
  • The header typically specifies the algorithm used to sign the token (e.g., HMAC, RSA, or ECDSA) and the token type.
  • The payload contains the data the token encodes (aka claims). Think things like user ID, roles, permissions, and custom attributes.
  • The signature is the most critical part, ensuring the token's integrity and confirming that it was issued by a trusted source.

JWTs are protected via JSON Web Signature (JWS). JWS uses a signature algorithm and is used to share data between parties when confidentiality is not required. This is because claims within a JWS can be read as they are simply base64-encoded (but carry with them a signature for authentication).Some of the cryptographic algorithms JWS uses are HMAC, RSA, and ECDSA.

Alg Description Use Case
HS256 HMAC with shared secret Simple systems, internal services
RS256 RSA with public/private keys Authorization servers, external IdPs
ES256 ECDSA with elliptic-curve keys Modern IdPs, compact signatures

JWT validation library for Go

jwt-go is a great Golang implementation of the JWT spec. To install the package, run:

  
go get -u github.com/golang-jwt/jwt/v5
  

This library provides robust support for:

  • Token parsing
  • Claims validation
  • Signature verification
  • Custom claims types

We’ll use it throughout this guide.

Generating your keys

First, you need to have a set of cryptographic keys in order to sign your tokens.

In this tutorial, we will be using RS256. This asymmetric algorithm requires two keys: a private key to sign the token and a public key to verify it. If you already have them, move along to the next section.

!!Asymmetric algorithms use a pair of public and private keys to sign and verify the tokens. They are more secure, scalable, and better for secure, distributed systems but also more resource-intensive and complex. For more on the various algorithms see Which algorithm should you use to sign JWTs?!!

There are many ways to generate your keys. You could generate your keys using OpenSSL and save them as raw PEM files that your code would read.

  
# Generate private key
openssl genpkey -algorithm RSA -out private_key.pem -pkeyopt rsa_keygen_bits:2048

# Extract the public key
openssl rsa -pubout -in private_key.pem -out public_key.pem
  

However, this is not a best practice. Instead, you should use JSON Web Key Sets (JWKS), especially in distributed or cloud environments.

!!JWKS simplifies key rotation by allowing services to fetch the latest keys from a central endpoint, making updates easier and reducing the risk of errors. In contrast, PEM files require manual distribution and updates, which can be cumbersome in large systems. JWKS also centralizes key distribution, ensuring that all services or clients always have the correct keys without the need for constant manual updates.!!

If you are using a third-party identity provider (like WorkOS), they automatically generate and expose a JWKS endpoint for you. This allows clients to dynamically fetch the public keys needed for JWT verification without you having to manage the keys manually. WorkOS offers a public JWKS endpoint:

  
https://api.workos.com/sso/jwks/your-client_id
  

Clients and APIs can use this endpoint to retrieve the public keys needed to validate JWTs signed by WorkOS. Key rotation, expiration, and distribution are handled automatically by the provider.

If you’re not using a third-party identity provider and want to create and manage your own JWKS in Go, you’ll need to:

  1. Generate a key pair (public and private keys). You can generate a key pair using the crypto/rsa package, which relies on crypto/rand for secure random number generation.
  2. Create a JWKS endpoint: You'll need to expose the public keys as part of a JWKS endpoint. This endpoint (/well-known/jwks.json) will serve the JSON Web Key Set, which clients and services can use to validate JWTs.
  3. Handle key rotation and management: Key rotation is an important part of managing your JWKS. Periodically generating new key pairs and updating the JWKS helps to maintain security. For example, you can rotate keys every few months or whenever you suspect key compromise. When you rotate keys, you can add a new key to your JWKS and mark the old one as inactive. Use a key identifier (kid) to distinguish between active and inactive keys.
  4. Secure your private keys: It's crucial to keep your private keys secure. They should never be exposed through your API or any public endpoint. Store them securely, for instance, in an encrypted file or a secure EKM like WorkOS Vault.

!!If you need something fast for a proof-of-concept, you can use a tool like mkjwk.org to generate a JWK.!!

Generating an RSA key pair and JWKS in Go

First, make sure you have a Go module:

  
go mod init example.com/jwt-demo
go get github.com/golang-jwt/jwt/v5
  

Now, here is an example of generating an RSA key pair and building a JWKS JSON in Go:

  
package main

import (
    "crypto/rand"
    "crypto/rsa"
    "crypto/x509"
    "encoding/base64"
    "encoding/json"
    "fmt"
    "math/big"
)

type JWK struct {
    Kty string `json:"kty"`
    Kid string `json:"kid"`
    Use string `json:"use"`
    Alg string `json:"alg"`
    N   string `json:"n"`
    E   string `json:"e"`
}

type JWKS struct {
    Keys []JWK `json:"keys"`
}

func main() {
    // Generate RSA private key
    privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
    if err != nil {
        panic(err)
    }

    publicKey := &privateKey.PublicKey
    pubASN1, err := x509.MarshalPKIXPublicKey(publicKey)
    if err != nil {
        panic(err)
    }

    // Extract modulus (n) and exponent (e) for JWK format
    n := publicKey.N
    e := big.NewInt(int64(publicKey.E))

    // JWK requires base64url-encoded (no padding) values of n and e
    nBytes := n.Bytes()
    eBytes := e.Bytes()

    nEnc := base64.RawURLEncoding.EncodeToString(nBytes)
    eEnc := base64.RawURLEncoding.EncodeToString(eBytes)

    jwks := JWKS{
        Keys: []JWK{
            {
                Kty: "RSA",
                Kid: "1",      // any unique identifier
                Use: "sig",    // used for signatures
                Alg: "RS256",  // signing algorithm
                N:   nEnc,
                E:   eEnc,
            },
        },
    }

    j, err := json.Marshal(jwks)
    if err != nil {
        panic(err)
    }

    fmt.Println("JWKS:", string(j))

    // At this point, you would:
    // - store privateKey in a secure vault
    // - expose `j` via an HTTP handler (e.g. /well-known/jwks.json)
}
  

If you are using a vault to store your private key, now is the time to do it. For WorkOS Vault the SDK call looks like this:

  
package main

import (
    "context"

    "github.com/workos/workos-go/v4/pkg/vault"
)

func main() {
    vault.SetAPIKey(
        "YOUR_API_KEY",
    )

    objectMetadata, err := vault.CreateObject(
        context.Background(),
        vault.CreateObjectOpts{
            Name:       "secret-name",
            Value:      "my secret value",
            KeyContext: vault.KeyContext{"organization_id": "YOUR_ORG_ID"},
        },
    )
}
  

The response will contain an id which you can use whenever you want to retrieve the value in order to sign a JWT:

  
package main

import (
    "context"

    "github.com/workos/workos-go/v4/pkg/vault"
)

func main() {
    vault.SetAPIKey(
        "YOUR_API_KEY",
    )

    object, err := vault.ReadObject(
        context.Background(),
        vault.ReadObjectOpts{
            Id: "YOUR_ID",
        },
    )
}
  

For more on this, see the WorkOS Vault API.

Creating a JWT in Go

Once you have your RSA keys, you can create and sign a token using the private key. In this example, to keep things simple, we’ll read the private key from a local PEM file. In production, you’d typically pull it from a key vault.

We’ll use github.com/golang-jwt/jwt/v5.

  
package main

import (
    "crypto/rsa"
    "crypto/x509"
    "encoding/pem"
    "fmt"
    "os"
    "time"

    "github.com/golang-jwt/jwt/v5"
)

type CustomClaims struct {
    Username      string            `json:"username"`
    Roles         []string          `json:"roles"`
    EmailVerified bool              `json:"email_verified"`
    Department    string            `json:"department"`
    FeatureFlags  map[string]bool   `json:"feature_flags"`
    jwt.RegisteredClaims
}

func loadPrivateKeyFromPEM(path string) (*rsa.PrivateKey, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }

    block, _ := pem.Decode(data)
    if block == nil {
        return nil, fmt.Errorf("failed to decode PEM")
    }

    priv, err := x509.ParsePKCS1PrivateKey(block.Bytes)
    if err != nil {
        return nil, err
    }

    return priv, nil
}

func main() {
    privateKey, err := loadPrivateKeyFromPEM("private_key.pem")
    if err != nil {
        panic(err)
    }

    now := time.Now().UTC()

    claims := CustomClaims{
        Username:      "john_doe",
        Roles:         []string{"admin", "editor"},
        EmailVerified: true,
        Department:    "engineering",
        FeatureFlags: map[string]bool{
            "beta_access": true,
            "dark_mode":   false,
        },
        RegisteredClaims: jwt.RegisteredClaims{
            Subject:   "123",                                  // sub
            ExpiresAt: jwt.NewNumericDate(now.Add(time.Hour)), // exp
            IssuedAt:  jwt.NewNumericDate(now),                // iat
            NotBefore: jwt.NewNumericDate(now),                // nbf
            Issuer:    "my_issuer",                            // iss
            Audience:  []string{"my_audience"},                // aud
        },
    }

    token := jwt.NewWithClaims(jwt.SigningMethodRS256, claims)

    tokenString, err := token.SignedString(privateKey)
    if err != nil {
        panic(err)
    }

    fmt.Println("Encoded JWT (signed):", tokenString)
}
  

This snippet:

  1. Loads an RSA private key from private_key.pem.
  2. Builds a payload with both standard and custom claims.
  3. Uses jwt.NewWithClaims and SigningMethodRS256 to sign the token.
  4. Prints the signed JWT.

A signed token will look like a long Base64URL-encoded string:

  
Encoded JWT (signed):eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsImtpZCI6ImFhYjAzNDVjYzFkZmQ5NTljODIwZjBlZGZiMDMxZDI4In0.eyJ1c2VyX2lkIjoxMjMsInVzZXJuYW1lIjoiam9obl9kb2UiLCJyb2xlcyI6WyJhZG1pbiIsImVkaXRvciJdLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZSwiZGVwYXJ0bWVudCI6ImVuZ2luZWVyaW5nIiwiZmVhdHVyZV9mbGFncyI6eyJiZXRhX2FjY2VzcyI6dHJ1ZSwiZGFya19tb2RlIjpmYWxzZX0sImV4cCI6MTc0NDMwNjMyNSwiaWF0IjoxNzQ0MzA2MzI1LCJuYmYiOjE3NDQzMDYzMjUsImlzcyI6Im15X2lzc3VlciIsImF1ZCI6Im15X2F1ZGllbmNlIn0.GMm4kr6gwgokyPoxIiFX249YY1PjN2T9E2_MeGC1h2RhvU6YESby-eiMZeFYtl6VwxjUuBqR-lfPyQJhczSoKfsEykA3fzvfLjWwK9bK2VjpOxXsW_d4XeAy4HjL-iPYwpfh2RmxcU6xX1DluW2BKbueIbC3KP3kllr4RrfNQGXDezQ9CvE3TjGlh0ptmqQGQLPbDHI4NfM9wJ9f0gD1DEgffJwSzBwY7CYbJhxR58VFgtPrY94mApJHS_8ehf7Os6y8zjGTjtCYIYMzvo-Jajq1o8OTgNpvrx06DK7kkMMqeeAlrmFo71rYUt1F7vus4iA-75WAN5AQfJOl8OM8KQ
  

Sending the token as a Bearer token in Go

Once the app has the JWT, it can use it to authenticate itself when calling another service, typically by sending it in the Authorization header as a Bearer token.

The Bearer prefix tells the API: “Whoever bears this token can use it.”

Setting the token as Bearer looks like this:

  
package main

import (
    "fmt"
    "io"
    "net/http"
)

func main() {
    jwtToken := "YOUR_JWT_HERE"
    url := "https://api.example.com/protected/resource"

    req, err := http.NewRequest("GET", url, nil)
    if err != nil {
        panic(err)
    }

    req.Header.Set("Authorization", "Bearer "+jwtToken)
    req.Header.Set("Content-Type", "application/json")

    client := &http.Client{}
    resp, err := client.Do(req)
    if err != nil {
        panic(err)
    }
    defer resp.Body.Close()

    body, _ := io.ReadAll(resp.Body)

    if resp.StatusCode == http.StatusOK {
        fmt.Println("Success! Data returned:")
        fmt.Println(string(body))
    } else {
        fmt.Printf("Request failed with status code %d\n", resp.StatusCode)
        fmt.Println(string(body))
    }
}
  

Adding standard and custom claims

The payload of each JWT consists of claims. These are pieces of information that provide details about the user and can be either standard (as defined by RFC 7519) or custom:

  • Standard claims serve common use cases for identifying the token, setting validity, and establishing trust.
  • Custom claims are created by each app to add specific context or permissions.

In the example we used, we added to our JWT the following standard claims:

  • sub: subject — what the token is about (this is the user’s unique identifier)
  • exp: expiration time — when the token expires.
  • iat: issued at — when the token was issued.
  • nbf: not before — when the token becomes valid.
  • iss: issuer — who issued the token.
  • aud: audience — who the token is intended for.

These are technically optional, but several of them are critical during verification (exp, iss, aud in particular), which we will see later.

We also added the following custom claims:

  • username: the user’s username for UI display.
  • roles: the user’s assigned permissions so the app knows what the user can see and do.
  • email_verified: boolean flag for whether the user has verified their email or not.
  • department: the department the user belongs to.
  • feature_flags: dictionary of enabled experimental features.

Custom claims let your application logic leverage token data directly without needing extra DB lookups (but balance that with token size and security).

In Go, we encoded these as a typed struct (CustomClaims) embedding jwt.RegisteredClaims.

Decoding a JWT

The app or API that is on the receiving end needs to verify the JWT’s signature using the corresponding public key, and then validate the token's claims (like expiration, audience, etc.).

In our example, we use RS256, which means that the receiving app needs to know the public key. The app will typically receive the public key in one of these ways:

  • Directly from a .pem file or env variable
  • Fetched from a JWKS (JSON Web Key Set) endpoint, like: https://auth.example.com/.well-known/jwks.json

Here’s a basic example that fetches the public key from a JWKS using github.com/MicahParks/keyfunc to fetch and manage JWKS.

First, install the package:

  
go get github.com/MicahParks/keyfunc
  

Then, fetch the public key and verify the JWT:

  
package main

import (
    "fmt"
    "log"

    "github.com/MicahParks/keyfunc"
    "github.com/golang-jwt/jwt/v5"
)

type CustomClaims struct {
    Username      string          `json:"username"`
    Roles         []string        `json:"roles"`
    EmailVerified bool            `json:"email_verified"`
    Department    string          `json:"department"`
    FeatureFlags  map[string]bool `json:"feature_flags"`
    jwt.RegisteredClaims
}

func main() {
    tokenString := "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."

    jwksURL := "https://auth.example.com/.well-known/jwks.json"

    jwks, err := keyfunc.Get(jwksURL, keyfunc.Options{})
    if err != nil {
        log.Fatalf("failed to get JWKS: %v", err)
    }

    claims := &CustomClaims{}

    token, err := jwt.ParseWithClaims(
        tokenString,
        claims,
        jwks.Keyfunc,
        jwt.WithAudience("your-api-audience"),
        jwt.WithIssuer("https://auth.example.com/"),
    )
    if err != nil {
        log.Printf("Invalid token: %v", err)
        return
    }

    if !token.Valid {
        log.Println("Token is not valid")
        return
    }

    fmt.Println("JWT is valid. Claims:", claims)
}
  

What’s happening here:

  1. keyfunc.Get fetches and caches the JWKS.
  2. jwks.Keyfunc picks the correct public key based on the kid in the JWT header.
  3. jwt.ParseWithClaims:
    • Parses the token.
    • Verifies the signature using the keyfunc.
    • Validates standard claims, including exp, aud, and iss (because we passed options).

If you have a local PEM public key file instead:

  
func loadPublicKeyFromPEM(path string) (*rsa.PublicKey, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return nil, err
    }

    block, _ := pem.Decode(data)
    if block == nil {
        return nil, fmt.Errorf("failed to decode PEM")
    }

    pub, err := x509.ParsePKIXPublicKey(block.Bytes)
    if err != nil {
        return nil, err
    }

    rsaPub, ok := pub.(*rsa.PublicKey)
    if !ok {
        return nil, fmt.Errorf("not RSA public key")
    }

    return rsaPub, nil
}

func verifyWithPEM(tokenString string) {
    publicKey, err := loadPublicKeyFromPEM("public_key.pem")
    if err != nil {
        panic(err)
    }

    claims := &CustomClaims{}
    token, err := jwt.ParseWithClaims(
        tokenString,
        claims,
        func(t *jwt.Token) (interface{}, error) {
            if _, ok := t.Method.(*jwt.SigningMethodRSA); !ok {
                return nil, fmt.Errorf("unexpected signing method: %v", t.Header["alg"])
            }
            return publicKey, nil
        },
        jwt.WithAudience("my_audience"),
        jwt.WithIssuer("my_issuer"),
    )
    if err != nil {
        log.Printf("Invalid token: %v", err)
        return
    }

    if !token.Valid {
        log.Println("Token is not valid")
        return
    }

    fmt.Println("Token is valid:", claims)
}
  

In this setup, ParseWithClaims:

  1. Ensures the algorithm matches your expectations (RS256).
  2. Uses the provided public key to verify the signature.
  3. Validates expiration, not-before, audience, and issuer.

About the kid claim

You only need to care about kid when you use JWKS with multiple keys (for rotation).

A JWT header might look like:

  
{
  "alg": "RS256",
  "typ": "JWT",
  "kid": "abc123"
}
  

kid is a key ID that tells your application which key in the JWKS was used to sign the token.

Example JWKS:

  
{
  "keys": [
    {
      "kty": "RSA",
      "alg": "RS256",
      "kid": "abc123",
      "n": "...",
      "e": "..."
    },
    {
      "kty": "RSA",
      "alg": "RS256",
      "kid": "xyz789",
      "n": "...",
      "e": "..."
    }
  ]
}
  

When your app receives a JWT, it:

  1. Reads the kid from the header.
  2. Looks up the corresponding key inside the JWKS.
  3. Uses that key to verify the token’s signature.

With Go + keyfunc, all of that is handled for you by jwks.Keyfunc: it fetches the JWKS, chooses the right key based on kid, and verifies the signature.

If you want to log the kid, you can parse the header without verifying the token:

  
parser := jwt.NewParser(jwt.WithoutClaimsValidation())

token, _, err := parser.ParseUnverified("eyJhbGciOiJSUzI1NiIsImtpZCI6ImFiYzEyMyJ9...", jwt.MapClaims{})
if err != nil {
    log.Fatalf("failed to parse header: %v", err)
}

kid, _ := token.Header["kid"].(string)
log.Println("Token signed with key ID:", kid)
  

Verifying a JWT

After the signature has been verified, you need to validate the claims. This includes both the standard and the custom ones.

For standard claims, golang-jwt/jwt handles most of the heavy lifting, especially when you use parser options like jwt.WithAudience and jwt.WithIssuer.

Example:

  
claims := &CustomClaims{}

token, err := jwt.ParseWithClaims(
    tokenString,
    claims,
    func(t *jwt.Token) (interface{}, error) {
        return publicKey, nil // or jwks.Keyfunc
    },
    jwt.WithAudience("my_audience"),
    jwt.WithIssuer("my_issuer"),
)
if err != nil {
    // Different errors for different failure cases
    if errors.Is(err, jwt.ErrTokenExpired) {
        fmt.Println("Token has expired")
        return
    }
    if errors.Is(err, jwt.ErrTokenIssuerInvalid) {
        fmt.Println("Invalid issuer")
        return
    }
    if errors.Is(err, jwt.ErrTokenAudienceInvalid) {
        fmt.Println("Invalid audience")
        return
    }
    fmt.Println("Invalid token:", err)
    return
}

if !token.Valid {
    fmt.Println("Invalid signature or malformed token")
    return
}

fmt.Println("Token is valid:", claims)
  

This call to ParseWithClaims:

  1. Verifies the signature.
  2. Checks exp, nbf, aud, and iss according to the options you pass.

If you’re building a REST API, you’ll likely wrap this in middleware and convert failures into HTTP responses.

Here’s a simple example of a JWT middleware in a Go HTTP server using net/http:

  
package main

import (
    "encoding/json"
    "log"
    "net/http"
    "strings"

    "github.com/MicahParks/keyfunc"
    "github.com/golang-jwt/jwt/v5"
)

type CustomClaims struct {
    Username      string          `json:"username"`
    Roles         []string        `json:"roles"`
    EmailVerified bool            `json:"email_verified"`
    Department    string          `json:"department"`
    FeatureFlags  map[string]bool `json:"feature_flags"`
    jwt.RegisteredClaims
}

type ctxKey string

const claimsContextKey ctxKey = "jwtClaims"

func JWTMiddleware(jwks *keyfunc.JWKS) func(http.Handler) http.Handler {
    return func(next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
            authHeader := r.Header.Get("Authorization")
            if authHeader == "" {
                http.Error(w, `{"error":"Authorization token is missing"}`, http.StatusUnauthorized)
                return
            }

            tokenString := strings.TrimPrefix(authHeader, "Bearer ")
            tokenString = strings.TrimSpace(tokenString)

            claims := &CustomClaims{}

            token, err := jwt.ParseWithClaims(
                tokenString,
                claims,
                jwks.Keyfunc,
                jwt.WithAudience("my_audience"),
                jwt.WithIssuer("my_issuer"),
            )
            if err != nil {
                http.Error(w, `{"error":"Invalid token"}`, http.StatusUnauthorized)
                return
            }

            if !token.Valid {
                http.Error(w, `{"error":"Invalid token"}`, http.StatusUnauthorized)
                return
            }

            // Attach claims to context
            ctx := r.Context()
            ctx = context.WithValue(ctx, claimsContextKey, claims)

            next.ServeHTTP(w, r.WithContext(ctx))
        })
    }
}

func protectedHandler(w http.ResponseWriter, r *http.Request) {
    claims, ok := r.Context().Value(claimsContextKey).(*CustomClaims)
    if !ok || claims == nil {
        http.Error(w, `{"error":"missing claims"}`, http.StatusInternalServerError)
        return
    }

    resp := map[string]interface{}{
        "message": "This is a protected route",
        "user_id": claims.Subject,
        "roles":   claims.Roles,
    }
    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(resp)
}

func publicHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    _ = json.NewEncoder(w).Encode(map[string]string{
        "message": "Public route accessible without token",
    })
}

func main() {
    jwksURL := "https://auth.example.com/.well-known/jwks.json"
    jwks, err := keyfunc.Get(jwksURL, keyfunc.Options{})
    if err != nil {
        log.Fatalf("failed to create JWKS from URL: %v", err)
    }

    mux := http.NewServeMux()
    mux.HandleFunc("/", publicHandler)
    mux.Handle("/protected", JWTMiddleware(jwks)(http.HandlerFunc(protectedHandler)))

    log.Println("Listening on :8080")
    log.Fatal(http.ListenAndServe(":8080", mux))
}
  

Handling custom claims

Besides always validating the standard claims for optimal security, you will also want to validate the standard ones before using them. Things like roles, email_verified, or department:

  
func enforceCustomClaims(claims *CustomClaims) error {
    if !claims.EmailVerified {
        return fmt.Errorf("email not verified")
    }

    isAdmin := false
    for _, role := range claims.Roles {
        if role == "admin" {
            isAdmin = true
            break
        }
    }
    if !isAdmin {
        return fmt.Errorf("user is not an admin")
    }

    return nil
}
  

This way, you can implement your business logic checks and use the information included in the JWT’s payload.

You can call this right after you validate the token:

  
if err := enforceCustomClaims(claims); err != nil {
    http.Error(w, `{"error":"`+err.Error()+`"}`, http.StatusForbidden)
    return
}
  

This is where your application’s authorization logic lives: you inspect the custom claims (roles, email_verified, department, etc.) and decide what the user is allowed to do.

JWT best practices (Go edition)

  • Always verify the signature. DO NOT decode JWTs without verifying the signature. Always specify the expected signing method (e.g., jwt.SigningMethodRS256) and supply the correct public key or JWKS keyfunc. Avoid using jwt.Parse without algorithm enforcement. Explicitly check the token’s method inside your key lookup function.
  • Use asymmetric signing (RS256 or ES256). Prefer RS256 (asymmetric signing) over HS256 (shared secret), especially in distributed systems. It separates concerns: the auth server signs, your apps verify. This separation of responsibilities eliminates the risk of a leaked shared secret.
  • Validate critical claims. Always validate:
    • exp (Expiration)
    • nbf (Not Before)
    • iat (Issued At)
    • iss (Issuer)
    • aud (Audience)
  • Use a JWKS endpoint if available. If your identity provider (like WorkOS) supports JWKS, use it to automatically fetch the correct public key by kid. If not, build one yourself. Make sure that private keys are stored safely and that you properly handle key rotation and management.
  • Enforce Bearer token format. Require the token to come in the Authorization header, in this format: Authorization: Bearer <jwt>. Strip the prefix in your code: tokenString := strings.TrimPrefix(authHeader, "Bearer ")
  • Do NOT store sensitive info in the token. Avoid putting PII, passwords, or access secrets in JWTs. JWTs are not encrypted by default; they’re just base64-encoded and signed.
  • Store sensitive info, like private keys, in a vault, instead of in pem files and environment variables.
  • Set short expiration times. Keep exp short (optimally up to 2 minutes for access tokens). Use refresh tokens if you need longer sessions (with secure storage, e.g., HTTP-only cookies).
  • Handle exceptions gracefully. Go’s JWT verification throws different errors for expiration, issuer mismatch, audience mismatch, bad signature, etc. Map these to clean HTTP responses: 401 Unauthorized for invalid or expired tokens, 403 Forbidden for valid but unauthorized tokens (more about 401 vs 403). Never leak raw JWTs or full error details in logs or responses.
  • Use HTTPS everywhere. Always require HTTPS in production to prevent token theft via MITM attacks. This is critical if you're passing JWTs in headers or cookies.
  • Modularize JWT logic. Create a reusable VerifyJWT(tokenString string) (*CustomClaims, error) utility or dedicated middleware. This keeps your handlers clean and ensures consistent enforcement across services.
  • Log token failures (carefully). Log kid, iss, and failure reasons, but never log full tokens, payloads, or signatures. Mask or redact anything sensitive.
  • Test with bad tokens. Always test edge cases:
    • Expired tokens
    • Tokens with wrong iss or aud
    • Tokens signed with the wrong key
    • Tokens with tampered payloads
    • Missing required claims
    • Wrong algorithm (none or HS256 attack vectors)

Outsourcing the heavy lifting

While handling JWTs directly in Go with libraries like golang-jwt/jwt and keyfunc is often necessary at the API layer, it’s worth stepping back and asking how those tokens are being issued in the first place. If you’re building authentication flows, especially ones involving Single Sign-On (SSO), SCIM provisioning, or multi-tenant identity, WorkOS can take care of most of that heavy lifting for you.

WorkOS provides a modern API for enterprise-ready authentication features, letting you integrate SSO (SAML, OIDC, etc.), manage users and directories, and issue secure tokens, all without maintaining your own auth stack or identity provider. It’s particularly helpful if you need to support enterprise customers or want to offer a “Login with your company” experience. And it costs nothing for up to 1,000,000 monthly active users.

So while your Go services focus on verifying JWTs, enforcing claims, and protecting APIs, WorkOS can own the upstream identity flows, directory sync, and token issuance, giving you a clean, standards-based way to plug authentication into your application.

This site uses cookies to improve your experience. Please accept the use of cookies on this site. You can review our cookie policy here and our privacy policy here. If you choose to refuse, functionality of this site will be limited.