Documentation
¶
Overview ¶
Package jwt provides JSON Web Token (JWT) authentication for authcore.
Tokens are signed with Ed25519 (alg=EdDSA) using keys managed by authcore's key manager. Token encoding is handled by github.com/golang-jwt/jwt/v5.
Token strategy ¶
Two token kinds are issued:
- Access token — short-lived (default 15 min), sent in Authorization: Bearer.
- Refresh token — long-lived (default 24 h), stored securely by the client.
Custom claims ¶
JWT is generic over T, which holds application-specific fields embedded in the access token payload under the "extra" key. The refresh token never carries custom claims.
type UserClaims struct {
Name string `json:"name"`
Role string `json:"role"`
}
jwtMod, _ := jwt.New[UserClaims](auth, jwt.DefaultConfig())
Storage model ¶
The library is storage-agnostic. It returns a hashed form of the refresh token that the application stores in its database. The raw token is never persisted by the library.
Typical server-side flow ¶
// 1. Initialise once at startup.
auth, _ := authcore.New(authcore.DefaultConfig())
jwtMod, _ := jwt.New[UserClaims](auth, jwt.DefaultConfig())
// 2. Login — create a token pair for the authenticated user.
pair, _ := jwtMod.CreateTokens(userID, UserClaims{Name: "Ana", Role: "admin"})
sendToBrowser(pair.AccessToken, pair.RefreshToken)
db.StoreRefreshHash(userID, pair.RefreshTokenHash)
// 3. Authenticated request — verify the access token on each call.
claims, err := jwtMod.VerifyAccessToken(accessToken)
if err != nil { ... } // errors.Is(err, jwt.ErrTokenExpired)
fmt.Println(claims.Extra.Name)
// 4. Token rotation — verify the hash first (timing-safe), then rotate.
if !jwtMod.VerifyRefreshTokenHash(clientToken, storedHash) {
return http.StatusUnauthorized
}
user, _ := db.GetUser(userID)
newPair, _ := jwtMod.RotateTokens(clientToken, UserClaims{Name: user.Name, Role: user.Role})
db.ReplaceRefreshHash(storedHash, newPair.RefreshTokenHash)
sendToBrowser(newPair.AccessToken, newPair.RefreshToken)
Index ¶
- Variables
- type Claims
- type Config
- type JWT
- func (j *JWT[T]) CreateTokens(subject string, extra T) (*TokenPair, error)
- func (j *JWT[T]) HashRefreshToken(token string) string
- func (j *JWT[T]) Name() string
- func (j *JWT[T]) RotateTokens(refreshToken string, extra T) (*TokenPair, error)
- func (j *JWT[T]) VerifyAccessToken(token string) (*Claims[T], error)
- func (j *JWT[T]) VerifyRefreshTokenHash(token, storedHash string) bool
- type TokenPair
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ( // ErrInvalidConfig is returned when jwt.Config fails validation. // // Safety: INTERNAL — programming or startup error, should never reach a handler. ErrInvalidConfig = errors.New("jwt: invalid configuration") // ErrTokenExpired is returned when a token's exp claim is in the past. // // Safety: CLIENT-SAFE — use to return a specific "token expired" message // so the client knows to refresh rather than re-authenticate. ErrTokenExpired = errors.New("jwt: token has expired") // ErrTokenInvalid is returned when the token signature does not verify, // or when an unsupported algorithm is present in the JOSE header. // // Safety: INTERNAL — the wrapped message may reveal the algorithm name. // Return a generic "unauthorized" to the client. ErrTokenInvalid = errors.New("jwt: token is invalid") // ErrTokenMalformed is returned when the token is not a properly formed // three-part dot-separated string, or when any part cannot be base64url-decoded. // // Safety: INTERNAL — return a generic "unauthorized" to the client. ErrTokenMalformed = errors.New("jwt: token is malformed") // ErrWrongTokenType is returned when an access token is passed to a // function that expects a refresh token, or vice-versa. // // Safety: INTERNAL — reveals the internal token type distinction. // Return a generic "unauthorized" to the client. ErrWrongTokenType = errors.New("jwt: wrong token type") // ErrInvalidSubject is returned when CreateTokens is called with a // subject that is not a valid UUID v7 (RFC 9562 §5.7, case-insensitive). // // Safety: INTERNAL — programming error in the caller. Treat as a 500. ErrInvalidSubject = errors.New("jwt: subject must be a valid UUID v7") )
Sentinel errors returned by the jwt package. Use errors.Is to check for these in calling code.
Error safety
Map errors to HTTP responses as follows — never forward err.Error() directly to clients, as wrapped messages may contain internal implementation details:
claims, err := jwtMod.VerifyAccessToken(token)
if err != nil {
log.Printf("token verification: %v", err) // log full detail
switch {
case errors.Is(err, jwt.ErrTokenExpired):
c.JSON(401, map[string]string{"error": "token expired"})
default:
c.JSON(401, map[string]string{"error": "unauthorized"})
}
return
}
Functions ¶
This section is empty.
Types ¶
type Claims ¶
type Claims[T any] struct { // Subject is the "sub" claim — the unique user identifier supplied // when CreateTokens was called. Subject string // Issuer is the "iss" claim as configured in jwt.Config.Issuer. Issuer string // Audience is the "aud" claim — the intended recipients of the token, // as configured in jwt.Config.Audience. Audience []string // TokenID is the "jti" claim — the unique identifier of this access token. TokenID string // IssuedAt is when the token was created (the "iat" claim). IssuedAt time.Time // ExpiresAt is when the token expires (the "exp" claim). ExpiresAt time.Time // Extra holds the application-specific claims embedded in the token. // These are the values passed to CreateTokens or RotateTokens. Extra T }
Claims represents the verified payload extracted from an access token. It is returned by VerifyAccessToken after successful signature and expiry validation.
T is the application-specific type passed to jwt.New. It corresponds to the "extra" field in the token payload.
type Config ¶
type Config struct {
// AccessTokenTTL is the lifetime of access tokens.
// Defaults to 15 minutes. Capped at 24 hours — longer values risk
// turning a bearer token into an effectively permanent credential.
AccessTokenTTL time.Duration
// RefreshTokenTTL is the lifetime of refresh tokens.
// Must be strictly greater than AccessTokenTTL.
// Defaults to 24 hours. Capped at 365 days.
RefreshTokenTTL time.Duration
// Issuer is the value of the "iss" claim in every token issued by this
// module, and the value VerifyAccessToken / RotateTokens require the
// "iss" claim to match on verification. Tokens whose iss does not equal
// this string are rejected with ErrTokenInvalid.
//
// Defaults to "github.com/Jaro-c/authcore".
// Override this with your own service URL or identifier (e.g. "https://auth.example.com").
Issuer string
// Audience is the list of intended recipients embedded in the "aud" claim
// of every token issued by this module. Verifiers use this to confirm
// that a token was issued for their service.
//
// On verification, only the **first** value (Audience[0]) is enforced.
// It is snapshotted into a private field at New() time so that a caller
// who later mutates the slice they passed in cannot panic or weaken the
// verification path. If you need to accept tokens for multiple audiences
// today, run one JWT module per audience rather than widening this slice.
//
// Defaults to ["github.com/Jaro-c/authcore"].
// Override this with your own service identifiers (e.g. ["https://api.example.com"]).
Audience []string
// ClockSkewLeeway is the tolerance applied when validating the "exp" and "iat" claims.
// It compensates for small clock differences between distributed servers.
// Defaults to 0 (no leeway). A value of 30 seconds is typical for production deployments.
// Must not be negative.
ClockSkewLeeway time.Duration
}
Config holds the JWT module configuration. All fields have safe defaults; use DefaultConfig() as the starting point.
func DefaultConfig ¶
func DefaultConfig() Config
DefaultConfig returns a Config with safe, production-ready defaults.
cfg := jwt.DefaultConfig()
cfg.AccessTokenTTL = 5 * time.Minute // tighten for high-security APIs
cfg.Issuer = "https://auth.example.com"
cfg.Audience = []string{"https://api.example.com"}
cfg.ClockSkewLeeway = 30 * time.Second // recommended for distributed deployments
jwtMod, err := jwt.New[MyClaims](auth, cfg)
type JWT ¶
type JWT[T any] struct { // contains filtered or unexported fields }
JWT is the authentication module for JSON Web Tokens.
T is the application-specific type embedded in access token payloads under the "extra" key. Use struct{} if no custom claims are needed.
Construct one instance at application startup using New and share it across all goroutines. JWT is safe for concurrent use after construction.
func New ¶
New creates and returns a JWT module.
T is the application-specific claims type embedded in access tokens. Use struct{} if no custom claims are needed:
jwtMod, err := jwt.New[struct{}](auth, jwt.DefaultConfig())
p provides the Ed25519 signing keys, the HMAC secret, the logger, and the timezone — all sourced from the parent AuthCore instance. cfg controls token lifetimes and the issuer claim.
Example ¶
package main
import (
"fmt"
"os"
"github.com/Jaro-c/authcore"
"github.com/Jaro-c/authcore/auth/jwt"
)
type AppClaims struct {
Role string `json:"role"`
}
func main() {
dir, _ := os.MkdirTemp("", "authcore-example-")
defer func() { _ = os.RemoveAll(dir) }()
auth, _ := authcore.New(authcore.Config{EnableLogs: false, KeysDir: dir})
mod, err := jwt.New[AppClaims](auth, jwt.DefaultConfig())
if err != nil {
panic(err)
}
fmt.Println(mod.Name())
}
Output: jwt
func (*JWT[T]) CreateTokens ¶
CreateTokens generates a new access and refresh token pair for subject.
subject is the UUID v7 that identifies the user in your system. It is stored in the "sub" JWT claim and returned in Claims.Subject after verification. Only UUID v7 is accepted (RFC 9562 §5.7); any casing is allowed — the value is normalised to lowercase before signing.
extra holds the application-specific claims embedded in the access token under the "extra" key. Use struct{}{} if no custom claims are needed. The refresh token never carries extra claims.
The returned TokenPair contains:
pair.AccessToken — include in Authorization: Bearer on API requests pair.AccessTokenExpiresAt — send to the client to schedule proactive renewal pair.RefreshToken — store in a secure, httpOnly client-side location pair.RefreshTokenExpiresAt — when the user must log in again pair.RefreshTokenHash — store in your database; never store the raw token pair.SessionID — UUID v7 jti shared by both tokens; primary key for session store
The access token's individual jti is available as claims.TokenID after VerifyAccessToken.
The library does not persist any of these values.
Example ¶
package main
import (
"fmt"
"os"
"github.com/Jaro-c/authcore"
"github.com/Jaro-c/authcore/auth/jwt"
)
type AppClaims struct {
Role string `json:"role"`
}
func main() {
dir, _ := os.MkdirTemp("", "authcore-example-")
defer func() { _ = os.RemoveAll(dir) }()
auth, _ := authcore.New(authcore.Config{EnableLogs: false, KeysDir: dir})
mod, _ := jwt.New[AppClaims](auth, jwt.DefaultConfig())
pair, err := mod.CreateTokens("018f0c8e-9b2a-7c3a-8b1e-1234567890ab", AppClaims{Role: "admin"})
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println("got pair:", pair.AccessToken != "" && pair.RefreshToken != "" && pair.SessionID != "")
}
Output: got pair: true
func (*JWT[T]) HashRefreshToken ¶
HashRefreshToken returns the HMAC-SHA256 hex digest of the given token string using the library's managed refresh secret.
Use this to derive the database lookup key before calling RotateTokens:
hash := jwtMod.HashRefreshToken(clientToken)
row, err := db.FindByHash(hash)
if err != nil { return http.StatusUnauthorized }
newPair, err := jwtMod.RotateTokens(clientToken, freshClaims)
func (*JWT[T]) RotateTokens ¶
RotateTokens verifies refreshToken, then generates and returns a new token pair for the same subject with fresh extra claims.
Because the refresh token does not carry application-specific data, the caller must supply updated extra claims (typically re-fetched from the database at rotation time).
The SessionID (jti) is preserved across rotations — only the token strings and their expiry times change. This means the caller's session record primary key remains stable for the entire session lifetime.
After a successful rotation, the old refresh token MUST be considered invalid. The application must replace the stored hash atomically:
db.ReplaceRefreshHash(oldHash, newPair.RefreshTokenHash)
This function validates the token's signature and expiry but does NOT check whether the hash exists in a database — that is the application's responsibility and must happen before calling RotateTokens.
Returns the same errors as VerifyAccessToken.
Example ¶
package main
import (
"fmt"
"os"
"github.com/Jaro-c/authcore"
"github.com/Jaro-c/authcore/auth/jwt"
)
type AppClaims struct {
Role string `json:"role"`
}
func main() {
dir, _ := os.MkdirTemp("", "authcore-example-")
defer func() { _ = os.RemoveAll(dir) }()
auth, _ := authcore.New(authcore.Config{EnableLogs: false, KeysDir: dir})
mod, _ := jwt.New[AppClaims](auth, jwt.DefaultConfig())
pair, _ := mod.CreateTokens("018f0c8e-9b2a-7c3a-8b1e-1234567890ab", AppClaims{Role: "user"})
newPair, err := mod.RotateTokens(pair.RefreshToken, AppClaims{Role: "admin"})
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Println(newPair.SessionID == pair.SessionID)
}
Output: true
func (*JWT[T]) VerifyAccessToken ¶
VerifyAccessToken parses and validates an access token string.
On success it returns the verified Claims extracted from the token payload, including the application-specific Extra fields. On failure it returns one of the following sentinel errors:
jwt.ErrTokenExpired — exp claim is in the past
jwt.ErrTokenInvalid — signature invalid, unsupported algorithm,
or iss/aud claim does not match Config
jwt.ErrTokenMalformed — not a valid three-part JWT string
jwt.ErrWrongTokenType — token is a refresh token, not an access token
Use errors.Is for error inspection:
claims, err := jwtMod.VerifyAccessToken(token)
if errors.Is(err, jwt.ErrTokenExpired) { ... }
Example ¶
package main
import (
"errors"
"fmt"
"os"
"github.com/Jaro-c/authcore"
"github.com/Jaro-c/authcore/auth/jwt"
)
type AppClaims struct {
Role string `json:"role"`
}
func main() {
dir, _ := os.MkdirTemp("", "authcore-example-")
defer func() { _ = os.RemoveAll(dir) }()
auth, _ := authcore.New(authcore.Config{EnableLogs: false, KeysDir: dir})
mod, _ := jwt.New[AppClaims](auth, jwt.DefaultConfig())
pair, _ := mod.CreateTokens("018f0c8e-9b2a-7c3a-8b1e-1234567890ab", AppClaims{Role: "admin"})
claims, err := mod.VerifyAccessToken(pair.AccessToken)
switch {
case errors.Is(err, jwt.ErrTokenExpired):
fmt.Println("expired")
case errors.Is(err, jwt.ErrTokenInvalid):
fmt.Println("invalid")
case err != nil:
fmt.Println("error:", err)
default:
fmt.Println(claims.Extra.Role)
}
}
Output: admin
func (*JWT[T]) VerifyRefreshTokenHash ¶
VerifyRefreshTokenHash reports whether token produces the same HMAC-SHA256 digest as storedHash using a constant-time comparison to prevent timing attacks.
Call this instead of a plain string equality check when validating a client's refresh token against the hash stored in your database:
if !jwtMod.VerifyRefreshTokenHash(clientToken, row.RefreshTokenHash) {
return http.StatusUnauthorized
}
newPair, err := jwtMod.RotateTokens(clientToken, freshClaims)
type TokenPair ¶
type TokenPair struct {
// AccessToken is the short-lived JWT for authenticating API requests.
// Include this in the Authorization: Bearer header.
// Default lifetime: 15 minutes.
AccessToken string
// AccessTokenExpiresAt is when AccessToken expires.
// Send this to the client so it can schedule a token refresh proactively.
AccessTokenExpiresAt time.Time
// RefreshToken is the long-lived JWT for obtaining new access tokens.
// Store this on the client side in secure, httpOnly storage.
// Default lifetime: 24 hours.
RefreshToken string
// RefreshTokenExpiresAt is when RefreshToken expires.
// After this time the user must log in again.
RefreshTokenExpiresAt time.Time
// RefreshTokenHash is the HMAC-SHA256 hex-encoded digest of RefreshToken.
//
// Store ONLY this value in your database — never the raw RefreshToken.
// When a client presents a refresh token, call HashRefreshToken to
// compute its hash and look it up in your database before calling
// RotateTokens.
RefreshTokenHash string
// SessionID is the UUID v7 shared by both the access and refresh tokens as their "jti" claim.
// It uniquely identifies the session. Use it as the primary key for your session store
// to associate metadata such as device, IP address, or last-seen time, and as
// the lookup key for access token revocation.
SessionID string
}
TokenPair holds the result of a successful token creation or rotation. The library never stores tokens — the application is responsible for persisting the RefreshTokenHash and associating it with the user.