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:
- 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.
JWT validation library for Go
jwt-go is a great Golang implementation of the JWT spec. To install the package, run:
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.
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:
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:
- Generate a key pair (public and private keys). You can generate a key pair using the
crypto/rsapackage, which relies oncrypto/randfor secure random number generation. - 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. - 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. - 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:
Now, here is an example of generating an RSA key pair and building a JWKS JSON in Go:
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:
The response will contain an id which you can use whenever you want to retrieve the value in order to sign a JWT:
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.
This snippet:
- Loads an RSA private key from
private_key.pem. - Builds a payload with both standard and custom claims.
- Uses
jwt.NewWithClaimsandSigningMethodRS256to sign the token. - Prints the signed JWT.
A signed token will look like a long Base64URL-encoded string:
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:
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
.pemfile 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:
Then, fetch the public key and verify the JWT:
What’s happening here:
keyfunc.Getfetches and caches the JWKS.jwks.Keyfuncpicks the correct public key based on thekidin the JWT header.jwt.ParseWithClaims:- Parses the token.
- Verifies the signature using the keyfunc.
- Validates standard claims, including
exp,aud, andiss(because we passed options).
If you have a local PEM public key file instead:
In this setup, ParseWithClaims:
- Ensures the algorithm matches your expectations (
RS256). - Uses the provided public key to verify the signature.
- 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:
kid is a key ID that tells your application which key in the JWKS was used to sign the token.
Example JWKS:
When your app receives a JWT, it:
- Reads the
kidfrom the header. - Looks up the corresponding key inside the JWKS.
- 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:
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:
This call to ParseWithClaims:
- Verifies the signature.
- Checks
exp,nbf,aud, andissaccording 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:
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:
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:
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 usingjwt.Parsewithout 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
Bearertoken format. Require the token to come in theAuthorizationheader, 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
pemfiles and environment variables. - Set short expiration times. Keep
expshort (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 Unauthorizedfor invalid or expired tokens,403 Forbiddenfor 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
issoraud - Tokens signed with the wrong key
- Tokens with tampered payloads
- Missing required claims
- Wrong algorithm (
noneor 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.