
GourdianToken v2.0.0: What's New and How to Migrate
After sixteen months and seven v1 releases, GourdianToken v2.0.0 is out β a breaking release that makes identifiers opaque, adds sentinel errors and a real Close(), and fixes some genuinely nasty bugs. Here's everything that changed and how to migrate.

Inside GourdianToken
Part 1 of 3 β’ Series Complete
The engineering behind GourdianToken v2.0.0 β a production-grade JWT library for Go: what shipped, how race-free refresh rotation works across four storage backends, and the bugs that almost made it into production.
Table of Contents
Today I released GourdianToken v2.0.0 β the first major version bump for my JWT lifecycle library for Go since I started building it in March 2025. v2 is a breaking release, but a deliberately small one: two breaking changes, both mechanical to migrate, bundled with a set of additions and bug fixes that had been accumulating on the v1 branch for months.
If you just want the upgrade recipe, jump to the migration steps below β for most codebases it's a ten-minute change. The rest of this post walks through why each change was made.
What GourdianToken does
GourdianToken handles the complete JWT lifecycle behind one interface: create, verify, revoke, and rotate, for both access and refresh tokens. Storage is pluggable β in-memory, Redis, GORM (Postgres/MySQL/SQLite), MongoDB, or fully stateless β and eleven signing algorithms are supported, from HS256 to EdDSA. Refresh rotation ships with replay-attack detection built in.
The two breaking changes
| Change | v1.x | v2.0.0 |
|---|---|---|
Module path | github.com/gourdian25/gourdiantoken | github.com/gourdian25/gourdiantoken/v2 |
Identifier types | uuid.UUID for ID, Subject, SessionID and the userID/sessionID parameters | Plain string everywhere β any non-empty userID; sessionID may even be empty |
Why identifiers became opaque strings
v1 required uuid.UUID for user and session identifiers. That felt rigorous when I wrote it, but in practice it was an over-constraint: the library never relied on UUID semantics β it just serialized the value into a claim. Meanwhile every consumer whose user IDs were strings, integers, or composite keys had to convert them into UUID shape, and every consumer had to import google/uuid whether they wanted to or not.
v2 treats identifiers as opaque. userID must be a non-empty string; sessionID may even be empty for sessionless tokens. Importantly, this is a widening of accepted input, not a narrowing β every previously-valid UUID string still works. And since google/uuid is now only used internally (to generate the jti token ID), consumers who pass their own identifiers no longer need that dependency at all.
1// v1.x β UUIDs required, .String() calls everywhere2userID := uuid.MustParse(user.ID)3token, err := maker.CreateAccessToken(ctx, userID, user.Name, roles, sessionID)4log.Println("issued for", claims.Subject.String())56// v2.0.0 β identifiers are opaque strings7token, err := maker.CreateAccessToken(ctx, user.ID, user.Name, roles, sessionID)8log.Println("issued for", claims.Subject) // already a stringWhat's new
- Sentinel errors for errors.Is: ErrTokenRevoked, ErrTokenRotated, ErrTokenExpired, ErrInvalidSignature, ErrInvalidToken, ErrInvalidClaims, ErrTokenRepositoryRequired, ErrMissingExpClaim, and ErrTokenMaxLifetimeExceeded. Existing error message text is preserved ahead of the wrapped sentinel, so callers that were string-matching keep working unchanged.
- GourdianTokenMakerCloser β an optional interface with an idempotent Close() that stops the background cleanup goroutines. It's a separate interface rather than an addition to GourdianTokenMaker, so external implementers of the main interface don't break.
- WithLogger functional option to control how cleanup goroutines report errors (default stays fmt.Printf-compatible).
- Exported claim-key constants (ClaimIssuer, ClaimAudience, ClaimNotBefore, ClaimMaxLifetimeExpiry) replacing bare string literals.
1claims, err := maker.VerifyRefreshToken(ctx, token)2switch {3case errors.Is(err, gourdiantoken.ErrTokenExpired):4 // normal expiry β client should re-authenticate5case errors.Is(err, gourdiantoken.ErrTokenRotated):6 // replay detected β treat as possible theft7case errors.Is(err, gourdiantoken.ErrTokenRevoked):8 // logged out β reject9}The bug fixes worth reading about
Three of the fixes in this release are interesting enough that they get their own posts in this series. The short versions:
- RotateRefreshToken could permanently lock a user out: the old token was marked rotated before the new one was created, so a creation failure left no valid token behind. v2 creates the new token first. (Full war story in part 3 of this series.)
- The MongoDB backend detected duplicate-key errors inside the transaction callback, which made commit behavior driver-version-dependent. Detection moved to the transaction boundary. (Covered in part 2.)
- RedisTokenRepository.Close() wasn't idempotent β a second call surfaced go-redis's "redis: client is closed". Now guarded with sync.Once.
- A dead-err bug in the verify and revoke paths produced the gloriously unhelpful message "invalid token: <nil>" β an already-nil-checked err was reused in a later error message.
- A doc comment claimed cleanup goroutines were stopped by garbage-collection finalization. They weren't (and can't be) β the real fix is the new Close().
Bonus dependency diet
v1 accidentally made testify a dependency of every consumer because two test-helper files were missing the _test.go suffix. They're renamed in v2 β importing gourdiantoken no longer pulls in a test framework.
Migration steps
- 1Update the module: go get github.com/gourdian25/gourdiantoken/v2@latest, then change every import to the /v2 path.
- 2Pass strings where you previously passed uuid.UUID to CreateAccessToken / CreateRefreshToken. If your IDs were already UUIDs, pass their string form β the values are still accepted.
- 3Delete .String() calls on claims.Subject, claims.SessionID, and response Subject/SessionID fields β they're already strings, and the compiler will point at every one.
- 4Optionally drop google/uuid from your go.mod if the library was your only reason for importing it.
- 5Optionally adopt the new toys: switch string-matched error checks to errors.Is, and call Close() on shutdown via the GourdianTokenMakerCloser interface.
Compiler-driven migration
Both breaking changes are type changes, so the Go compiler finds every affected line for you. There is no behavioral migration: token formats, claims, and verification semantics are unchanged, and v1-issued tokens verify fine under v2 with the same config.
Known issues, flagged honestly
Three inconsistencies are documented in the changelog but deliberately not fixed in this release: the revoke paths' internal jwt.Parse keyfuncs don't re-check the signing algorithm the way the verify paths do (low severity β the typed verification key already bounds algorithm-confusion attacks); the Mongo factory takes an extra positional transactionsEnabled bool that breaks the shape shared by the other factories; and MongoTokenRepository.Close(ctx) has a different signature than the other backends' bare Close(). Each needs its own design decision, and I'd rather ship them as known issues than rush unreviewed changes into a major release.
GourdianToken is MIT-licensed and on GitHub at gourdian25/gourdiantoken. The next two posts in this series dig into the two hardest problems the library solves: making refresh rotation race-free on four very different storage backends, and the ordering bug in rotation that could have locked users out for good.
Series ProgressComplete
Inside GourdianToken - 1 of 3
AI-readable content
Learn more βThis content is available via the AI Content API as JSON or token-efficient Markdown. Feed it directly into LLM workflows.
/api/content/blogs/gourdiantoken-v2-release-and-migration-guideRelated Articles
Continue your learning journey with these handpicked articles.


Related Content
Explore related articles, projects, and tools.