π‘οΈ authcore
Drop-in authentication for Go. Password hashing, JWT sessions, email + username validation β secure defaults, zero boilerplate.
Quick Start Β·
Why AuthCore? Β·
Modules Β·
Examples Β·
API Docs
What is AuthCore?
AuthCore is a Go library that handles the authentication plumbing most apps need β password hashing, login tokens, refresh/rotation, email + username validation β without forcing you to become a crypto expert. Import it, call three functions, and you have login that a security auditor would accept.
Written for Go 1.26+. No database. No HTTP framework. You plug those in.
go get github.com/Jaro-c/authcore
How it fits together
flowchart LR
App["Your App"] -->|init once| Core["authcore.AuthCore"]
Core -->|manages| Keys[("π Ed25519 keys<br/>HMAC secret<br/>(auto-generated, on disk)")]
Core -->|Provider| Mods
subgraph Mods["Plug-in modules"]
direction TB
JWT["auth/jwt<br/>access + refresh tokens"]
Pwd["auth/password<br/>Argon2id hashing"]
Email["auth/email<br/>validate + DNS MX"]
User["auth/username<br/>validate + reserved"]
end
JWT -->|sign / verify| Client["HTTP client"]
Pwd -->|hash / verify| DB[("Your database")]
Email -->|normalize| DB
User -->|normalize| DB
Pick only the modules you need. Each one is independent, testable, and safe by default.
Table of Contents
Why AuthCore?
You could roll your own β but password storage, token signing, and timing-safe comparison are the kind of things you only get wrong once. You could also pull in a full identity platform β but that's a lot of surface area for a login form.
AuthCore sits in the middle: a small, opinionated library that does the dangerous parts for you and leaves the rest to your app.
|
Roll your own |
Full IdP (Ory, Keycloak) |
AuthCore |
| Time to first login |
Hours β days |
Hours (with ops) |
5 minutes |
| Database included |
β (you build it) |
β
(theirs) |
β (bring your own β any DB works) |
| HTTP server included |
β |
β
(theirs) |
β (plug into any router) |
| Argon2id password hashing |
Manual |
β
|
β
|
| EdDSA access + refresh tokens |
Manual |
β
|
β
|
| Timing-safe comparisons |
Easy to forget |
β
|
β
|
| Automatic key management |
Manual |
β
|
β
|
| OAuth / OIDC provider |
β |
β
|
Planned |
| You own the data model |
β
|
β |
β
|
| Runs in-process, no extra service |
β
|
β |
β
|
Use AuthCore when you want security defaults without the infrastructure cost of a dedicated identity platform.
Modules at a Glance
| Module |
Does |
Import |
π auth/jwt |
Sign + verify access/refresh tokens. EdDSA / Ed25519. Generic custom claims. Rotation. |
github.com/Jaro-c/authcore/auth/jwt |
π auth/password |
Hash + verify passwords. Argon2id. Policy enforced. PHC format (self-describing). |
github.com/Jaro-c/authcore/auth/password |
π§ auth/email |
Validate + normalize addresses. RFC 5321/5322. Optional DNS MX check (cached). |
github.com/Jaro-c/authcore/auth/email |
π€ auth/username |
Validate + normalize usernames. Reserved-name blocklist. Character rules. |
github.com/Jaro-c/authcore/auth/username |
Each module works on its own β mix and match.
Features
- EdDSA / Ed25519 token signing β fast, constant-time, no padding-oracle risk
- Dual-token model β short-lived access tokens + long-lived refresh tokens
- Argon2id password hashing β memory-hard, GPU/ASIC-resistant, PHC format
- Email validation & normalization β RFC 5321/5322 compliance, optional DNS MX verification with cache
- Username validation & normalization β character rules, consecutive-special detection, fixed reserved name blocklist
- Automatic key management β generates, persists, and loads keys on first run
- Generic custom claims β embed any struct in access tokens with full type safety
- Timing-safe comparisons β
subtle.ConstantTimeCompare throughout
- Clock skew tolerance β configurable leeway for distributed deployments
- Pluggable logger β bring slog, zap, zerolog, or any custom backend
- Testable by design β injectable clock and
Provider interface for unit tests
- Minimal dependencies β
golang-jwt/jwt/v5, golang.org/x/crypto, golang.org/x/sync
Quick Start
package main
import (
"log"
"github.com/Jaro-c/authcore"
"github.com/Jaro-c/authcore/auth/jwt"
"github.com/Jaro-c/authcore/auth/password"
)
type UserClaims struct {
Name string `json:"name"`
Role string `json:"role"`
}
func main() {
// 1. One-time setup at startup.
// On first run, Ed25519 keys + HMAC secret are generated in ./.authcore/.
auth, err := authcore.New(authcore.DefaultConfig())
if err != nil {
log.Fatal(err)
}
// 2. Password hashing β zero config, OWASP-recommended Argon2id defaults.
pwdMod, err := password.New(auth)
if err != nil {
log.Fatal(err)
}
// 3. JWT tokens β set issuer + audience to your service URL in production.
jwtMod, err := jwt.New[UserClaims](auth, jwt.DefaultConfig())
if err != nil {
log.Fatal(err)
}
// Registration: hash the plaintext, store only the hash.
hash, err := pwdMod.Hash("Str0ng-P@ssword!")
if err != nil {
log.Fatal(err) // e.g. password.ErrWeakPassword β surface the reason to the user
}
// β db.StorePasswordHash(userID, hash)
// Login: verify the submitted password, then issue a token pair.
ok, err := pwdMod.Verify("Str0ng-P@ssword!", hash)
if err != nil || !ok {
log.Fatal("wrong password")
}
// userID must be a UUID v7 β the rest of your app can generate this.
userID := "019600ab-1234-7000-8000-000000000001"
pair, err := jwtMod.CreateTokens(userID, UserClaims{Name: "Ana", Role: "admin"})
if err != nil {
log.Fatal(err)
}
// pair.AccessToken β send as `Authorization: Bearer <token>`
// pair.RefreshToken β store client-side in a secure, httpOnly cookie
// pair.RefreshTokenHash β store server-side (never the raw refresh token)
// pair.SessionID β UUID v7 shared by both tokens β use as your session row PK
_ = pair
}
[!TIP]
Want a runnable version? Every module has a full example under examples/ β just go run ./examples/jwt/ to see it work end-to-end.
JWT Authentication
π Full reference β setup, login, auth, rotation, clock skew Β· click to expand
Setup
cfg := jwt.DefaultConfig()
cfg.Issuer = "https://auth.example.com"
cfg.Audience = []string{"https://api.example.com"}
// Optional: tolerate up to 30 s of clock drift between servers.
cfg.ClockSkewLeeway = 30 * time.Second
jwtMod, err := jwt.New[UserClaims](auth, cfg)
jwt.DefaultConfig() values:
| Field |
Default |
Max |
AccessTokenTTL |
15 minutes |
24 hours |
RefreshTokenTTL |
24 hours |
365 days |
Issuer |
"github.com/Jaro-c/authcore" |
β |
Audience |
["github.com/Jaro-c/authcore"] |
β |
ClockSkewLeeway |
0 (no leeway) |
β |
[!NOTE]
validateConfig rejects TTLs above the ceilings listed above. This prevents issuing effectively permanent bearer tokens by accident (e.g. typing 10 * time.Hour where 10 * time.Minute was intended).
Login β creating a token pair
// subject must be a UUID v7 (RFC 9562 Β§5.7).
pair, err := jwtMod.CreateTokens(userID, UserClaims{Name: "Ana", Role: "admin"})
if err != nil {
// jwt.ErrInvalidSubject β subject is not a valid UUID v7
}
pair.AccessToken // short-lived JWT for API requests
pair.AccessTokenExpiresAt // time.Time β tell the client when to refresh
pair.RefreshToken // long-lived JWT for token rotation
pair.RefreshTokenExpiresAt // time.Time β when the user must log in again
pair.RefreshTokenHash // HMAC-SHA256 hex digest β store this in your DB
pair.SessionID // UUID v7 jti shared by both tokens β use as session PK
Never store the raw refresh token. Store only RefreshTokenHash.
Authenticating requests
claims, err := jwtMod.VerifyAccessToken(tokenFromHeader)
switch {
case errors.Is(err, jwt.ErrTokenExpired):
// 401 β client should refresh
case errors.Is(err, jwt.ErrTokenInvalid):
// 401 β tampered, wrong key, or issuer/audience mismatch
case errors.Is(err, jwt.ErrTokenMalformed):
// 400 β not a JWT at all
case err != nil:
// 401 β catch-all
}
fmt.Println(claims.Subject) // UUID v7 user ID
fmt.Println(claims.Extra.Role) // "admin" β your custom claims
fmt.Println(claims.ExpiresAt) // time.Time
[!NOTE]
Verification enforces both iss (issuer) and aud (audience) match the values in jwt.Config. A token signed by a trusted key but minted for a different service is rejected with ErrTokenInvalid β this is the defense against accidental key reuse across services.
Rotating tokens
The recommended pattern β verify the hash before calling RotateTokens to prevent
token-reuse attacks even if your database is compromised:
// 1. Compute the hash of the token the client presented.
incoming := jwtMod.HashRefreshToken(clientToken)
// 2. Look it up in your database.
session, err := db.FindSessionByHash(incoming)
if err != nil {
return http.StatusUnauthorized
}
// 3. Use timing-safe comparison to verify the hash matches.
// This prevents timing attacks on the lookup result.
if !jwtMod.VerifyRefreshTokenHash(clientToken, session.RefreshTokenHash) {
return http.StatusUnauthorized
}
// 4. Rotate β authcore verifies the token's signature and expiry.
freshClaims := UserClaims{Name: session.UserName, Role: session.UserRole}
newPair, err := jwtMod.RotateTokens(clientToken, freshClaims)
if err != nil {
return http.StatusUnauthorized
}
// 5. Atomically replace the old hash in your database.
db.ReplaceRefreshHash(session.ID, newPair.RefreshTokenHash)
// 6. Send the new tokens to the client.
Clock skew tolerance
In distributed systems, server clocks may drift by a few seconds. Set ClockSkewLeeway
to accept tokens that expired within that window:
cfg.ClockSkewLeeway = 30 * time.Second
The leeway applies to both access and refresh token verification.
Keep it small β large values reduce the security margin of short-lived tokens.
Password Hashing
π Full reference β hashing, verifying, policy, tuning Β· click to expand
No boilerplate. No algorithm choices. Just secure password hashing that works.
Setup
auth, err := authcore.New(authcore.DefaultConfig())
// Zero-config β OWASP-recommended Argon2id defaults applied automatically.
pwdMod, err := password.New(auth)
That's it. No config required.
Why Argon2id? It's memory-hard: an attacker must allocate ~64 MiB of RAM
per attempt, making GPU and ASIC brute-force attacks prohibitively expensive.
bcrypt does not have this property.
Hashing a password
hash, err := pwdMod.Hash(userPassword)
switch {
case errors.Is(err, password.ErrWeakPassword):
// 400 β tell the user exactly what's missing (message is descriptive)
case err != nil:
// 500 β unexpected error
}
// Store hash in your database. Never store the plaintext.
db.StorePasswordHash(userID, hash)
Hash validates the password before spending CPU on hashing:
| Rule |
Requirement |
| Length |
12 β 64 characters |
| Uppercase |
At least one (AβZ, Unicode-aware) |
| Lowercase |
At least one (aβz, Unicode-aware) |
| Digit |
At least one (0β9) |
| Special |
At least one (anything that is not a letter or digit) |
Each call also generates a fresh random salt, so two hashes of the same
password are always different strings β but both verify correctly.
The stored string is fully self-describing (PHC format):
$argon2id$v=19$m=65536,t=3,p=2$<base64-salt>$<base64-hash>
Verifying a password
ok, err := pwdMod.Verify(submittedPassword, storedHash)
switch {
case errors.Is(err, password.ErrInvalidHash):
// 500 β hash in the database is malformed
case !ok:
// 401 β wrong password
}
Comparison is constant-time (crypto/subtle) β timing attacks are not
possible. Parameters are always read from the stored hash, never from the
current module config, and bounded to the same safe range (Memory 8 MiB β 4 GiB,
Iterations β€ 20, Parallelism β₯ 1) so a corrupted or malicious stored hash
cannot force argon2.IDKey into an unbounded memory allocation.
[!NOTE]
Both Hash and Verify normalise plaintext to Unicode NFC before
processing. A password like cafΓ© hashes the same whether the user typed
it on macOS (precomposed Γ©) or Linux (decomposed e + combining acute),
so cross-platform account access works out of the box.
Tuning work parameters (optional)
The defaults are sized for 2 vCPUs / 4 GiB RAM. On more powerful hardware,
crank them up β a hash should take roughly 200β500 ms:
pwdMod, err := password.New(auth, password.Config{
Memory: 128 * 1024, // 128 MiB
Iterations: 4,
Parallelism: 4, // match your guaranteed CPU core count
})
| Field |
Default |
Minimum |
Memory |
65536 (64 MiB) |
8192 (8 MiB) |
Iterations |
3 |
1 |
Parallelism |
2 |
1 |
Old hashes stay valid. All parameters live inside the hash string itself.
Changing the config only affects new hashes β existing users keep working.
Email Validation
π§ Full reference β validate, normalize, DNS MX Β· click to expand
Setup
emailMod, err := email.New(auth)
if err != nil {
log.Fatal(err)
}
defer emailMod.Close() // stops the background cache eviction goroutine
Validating and normalizing
Always call ValidateAndNormalize instead of validating and normalizing separately.
It lowercases, trims whitespace, and validates in a single call β ensuring the value
you store is always in canonical form:
normalized, err := emailMod.ValidateAndNormalize(req.Email)
switch {
case errors.Is(err, email.ErrInvalidEmail):
// 400 β tell the user exactly what failed (message is descriptive)
c.JSON(400, map[string]string{"error": errors.Unwrap(err).Error()})
return
case err != nil:
// 500 β unexpected error
}
// Store normalized β always lowercase, trimmed.
db.StoreUser(normalized, ...)
Validation rules (RFC 5321 / RFC 5322):
| Rule |
Requirement |
| Total length |
1 β 254 characters |
| Format |
One @ separating a non-empty local part and domain |
| Local part |
β€ 64 characters |
| Domain |
At least one dot; no leading, trailing, or consecutive dots |
| Domain labels |
1 β 63 characters each |
Always normalize before storing and before querying. This ensures consistent
lookup β User@EXAMPLE.COM and user@example.com are the same address.
[!NOTE]
Internationalised domains (IDN) are supported. Unicode domains are
converted to ASCII punycode during normalisation:
user@mΓΌnchen.de β user@xn--mnchen-3ya.de
user@δΎγ.jp β user@xn--r8jz45g.jp
Store the ASCII form so DNS lookups and database comparisons are deterministic.
Verifying a domain can receive email
VerifyDomain performs an optional DNS MX lookup to confirm the domain is
configured to receive email. Call it after ValidateAndNormalize when you
want to reject obviously fake domains before sending a verification email.
Results are cached per domain (default 5 minutes) and DNS lookups for the same
domain are deduplicated via singleflight β safe for high-concurrency workloads.
ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
defer cancel()
err = emailMod.VerifyDomain(ctx, normalized)
switch {
case errors.Is(err, email.ErrDomainNoMX):
// 400 β domain exists but cannot receive email
c.JSON(400, map[string]string{"error": "email domain cannot receive messages"})
return
case errors.Is(err, email.ErrDomainUnresolvable):
// DNS lookup failed β do NOT block the user; log and continue
log.Warn("DNS check unavailable: %v", err)
}
ErrDomainUnresolvable is a soft failure. DNS can be temporarily
unavailable due to network issues unrelated to the user's input. Never
block a registration on this error β log it and proceed.
Username Validation
π€ Full reference β validate, normalize, reserved names Β· click to expand
Setup
userMod, err := username.New(auth)
if err != nil {
log.Fatal(err)
}
Validating and normalizing
Always call ValidateAndNormalize β it lowercases, trims whitespace, and validates in a
single call, ensuring the value you store is always in canonical form:
normalized, err := userMod.ValidateAndNormalize(req.Username)
if err != nil {
// errors.Unwrap(err).Error() contains the specific rule that failed.
c.JSON(400, map[string]string{"error": errors.Unwrap(err).Error()})
return
}
db.StoreUser(normalized, ...) // always lowercase, trimmed, validated
Validation rules:
| Rule |
Requirement |
| Length |
3 β 32 characters (fixed) |
| Allowed characters |
[a-z0-9_-] only |
| First character |
Letter or digit (not _ or -) |
| Last character |
Letter or digit (not _ or -) |
| Consecutive specials |
__, --, _-, -_ are rejected |
| Reserved names |
Built-in blocklist (fixed) |
Always normalize before storing and before querying. Alice123 and alice123
are the same username β store only the canonical (normalized) form.
Key Management
ποΈ Full reference β files, persistence, kid header, containers Β· click to expand
On first run authcore creates KeysDir (default .authcore) and generates:
| File |
Format |
Mode |
Purpose |
ed25519_private.pem |
PKCS#8 PEM |
0600 |
Signing key |
ed25519_public.pem |
PKIX PEM |
0644 |
Verification key |
refresh_secret.key |
32-byte hex |
0600 |
HMAC-SHA256 key for refresh token hashing |
.gitignore |
* |
0600 |
Prevents accidental commits |
On subsequent starts the files are loaded and the key pair is validated for consistency.
If only one PEM file is present, New() returns ErrKeyManager β delete both to regenerate.
In containers or CI, point KeysDir at a mounted secrets volume:
cfg := authcore.DefaultConfig()
cfg.KeysDir = os.Getenv("AUTHCORE_KEYS_DIR") // e.g. /run/secrets/authcore
auth, err := authcore.New(cfg)
The KeyID() accessor returns a 16-character hex digest derived from the public key.
It is embedded in every token's kid JOSE header, enabling zero-downtime key rotation.
Verification rejects tokens whose kid does not match the module's current key id, so a
future multi-key deployment only ever accepts tokens minted under an authorised key.
[!NOTE]
Key-file loaders enforce a 4 KiB size cap. A healthy Ed25519 PEM is
~200 bytes; anything larger is refused before it reaches pem.Decode,
protecting startup from a corrupted or attacker-replaced key file that
would otherwise be loaded whole into memory.
Configuration
βοΈ Full reference β EnableLogs, Timezone, Logger, KeysDir Β· click to expand
type Config struct {
EnableLogs bool // emit log output; default true via DefaultConfig()
Timezone *time.Location // time zone for all operations; default time.UTC
Logger authcore.Logger // custom logger (slog, zap, zerolog, β¦); overrides EnableLogs
KeysDir string // key storage directory; default ".authcore"
}
Always start from DefaultConfig() and override only what you need:
cfg := authcore.DefaultConfig()
cfg.EnableLogs = false // silence output in tests
cfg.Logger = slog.Default() // use your application logger
cfg.KeysDir = "/run/secrets/authcore" // absolute path in containers
Note on EnableLogs: Go cannot distinguish EnableLogs = false from a zero-value
Config{}. Start from DefaultConfig() to get EnableLogs = true, then set it to
false to explicitly opt out.
Custom Logger
π Full reference β Logger interface, slog / zap / zerolog adapters Β· click to expand
Implement the Logger interface to route authcore output through your existing log pipeline:
type Logger interface {
Debug(msg string, args ...any)
Info(msg string, args ...any)
Warn(msg string, args ...any)
Error(msg string, args ...any)
}
*slog.Logger satisfies this interface directly:
cfg := authcore.DefaultConfig()
cfg.Logger = slog.Default() // or slog.New(yourHandler)
When Config.Logger is non-nil it takes precedence over EnableLogs.
Testing Your Auth Layer
π§ͺ Full reference β Provider stub recipe, deterministic time Β· click to expand
Every AuthCore module accepts a Provider interface β not a concrete *AuthCore β which means you never need to generate real keys or touch the disk in tests. Pass in a stub.
package mypkg_test
import (
"crypto/ed25519"
"testing"
"github.com/Jaro-c/authcore"
"github.com/Jaro-c/authcore/auth/jwt"
)
// stubProvider implements authcore.Provider with fixed, in-memory dependencies.
type stubProvider struct {
cfg authcore.Config
logger authcore.Logger
keys authcore.Keys
}
func (s *stubProvider) Config() authcore.Config { return s.cfg }
func (s *stubProvider) Logger() authcore.Logger { return s.logger }
func (s *stubProvider) Keys() authcore.Keys { return s.keys }
// stubKeys satisfies authcore.Keys with values you control in the test.
type stubKeys struct {
priv ed25519.PrivateKey
pub ed25519.PublicKey
secret []byte
kid string
}
func (k *stubKeys) PrivateKey() ed25519.PrivateKey { return k.priv }
func (k *stubKeys) PublicKey() ed25519.PublicKey { return k.pub }
func (k *stubKeys) RefreshSecret() []byte { return k.secret }
func (k *stubKeys) KeyID() string { return k.kid }
func TestMyHandler(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(nil)
p := &stubProvider{
cfg: authcore.DefaultConfig(),
logger: &noopLogger{},
keys: &stubKeys{priv: priv, pub: pub, secret: []byte("test-secret-32-bytes-long-xxxxxx"), kid: "test"},
}
jwtMod, err := jwt.New[struct{}](p, jwt.DefaultConfig())
if err != nil {
t.Fatal(err)
}
// ... exercise your handler against jwtMod, with no file I/O.
}
[!TIP]
For deterministic time in tests (e.g. to assert ExpiresAt), override Config.Timezone and use time.Now() equivalents through the same clock your production code reads from.
Migrating from bcrypt / other libraries
π Full reference β re-hash on next login pattern Β· click to expand
AuthCore can take over password verification from another library without forcing every user to reset their password. Use the "re-hash on next login" pattern:
func Login(email, submitted string) error {
user := db.FindUser(email)
// 1. Try authcore first. New users and already-migrated users land here.
if ok, _ := pwdMod.Verify(submitted, user.PasswordHash); ok {
return issueSession(user)
}
// 2. Fall back to the legacy hasher (e.g. bcrypt).
if !legacyBcrypt.Compare(submitted, user.PasswordHash) {
return ErrWrongPassword
}
// 3. Password is correct β upgrade the hash transparently.
newHash, err := pwdMod.Hash(submitted)
if err != nil {
return err
}
db.UpdatePasswordHash(user.ID, newHash)
return issueSession(user)
}
After a few weeks, most active users are migrated and you can delete the legacy path. Dormant accounts can be forced into password reset the next time they log in.
[!NOTE]
If your existing hashes are already in PHC Argon2id format ($argon2id$v=19$β¦), no migration is needed β pwdMod.Verify reads all parameters from the stored hash, regardless of which library produced it.
Project Layout
π Full tree β module organisation + import paths Β· click to expand
authcore/
βββ authcore.go # New() Β· AuthCore struct Β· compile-time interface assertions
βββ config.go # Config Β· DefaultConfig()
βββ logger.go # Logger interface Β· stdlib and noop implementations
βββ module.go # Keys Β· Provider Β· Module interfaces
βββ errors.go # Sentinel errors
β
βββ internal/
β βββ clock/ # Timezone-aware Clock β injected for deterministic tests
β βββ keymanager/ # Ed25519 + HMAC key generation, persistence, validation
β
βββ auth/
β βββ jwt/ # JSON Web Token authentication (EdDSA / Ed25519)
β βββ password/ # Argon2id password hashing
β βββ email/ # Email validation, normalization, DNS MX verification
β βββ username/ # Username validation, normalization, reserved name blocklist
β
βββ examples/
βββ basic/ # authcore initialisation strategies
βββ jwt/ # JWT: create, verify, rotate
βββ password/ # Password: policy, hash, verify
βββ email/ # Email: validate, normalize, DNS MX verification
βββ username/ # Username: validate, normalize, reserved names
βββ fiber/ # Full auth API with Fiber v3 (separate module)
βββ gin/ # Full auth API with Gin (separate module)
| Import path |
Visibility |
Purpose |
github.com/Jaro-c/authcore |
public |
Core types and entry point |
β¦/auth/jwt |
public |
JWT module |
β¦/auth/password |
public |
Argon2id password hashing module |
β¦/auth/email |
public |
Email validation, normalization, MX verification |
β¦/auth/username |
public |
Username validation, normalization, reserved names |
β¦/internal/clock |
internal |
Shared time abstraction |
β¦/internal/keymanager |
internal |
Key generation and persistence |
Writing a Module
π§© Full guide β Provider contract + minimal module skeleton Β· click to expand
Modules depend on authcore.Provider β not the concrete *AuthCore β so they remain
independently testable without touching the filesystem or generating real keys.
// Provider is the narrow interface that *AuthCore satisfies.
type Provider interface {
Config() Config // shared configuration
Logger() Logger // shared logger sink
Keys() Keys // Ed25519 keys + HMAC secret
}
// Module is the marker interface every sub-module must implement.
type Module interface {
Name() string // stable, lowercase identifier e.g. "jwt"
}
Minimal module skeleton:
package mypkg
import "github.com/Jaro-c/authcore"
type MyModule struct {
log authcore.Logger
// ...
}
func New(p authcore.Provider, cfg Config) (*MyModule, error) {
return &MyModule{log: p.Logger()}, nil
}
func (m *MyModule) Name() string { return "mypkg" }
In tests, inject a stub Provider that returns fixed keys β no disk I/O required.
Error Handling
π¨ Full reference β every sentinel error, per package Β· click to expand
authcore package
| Error |
When |
authcore.ErrInvalidConfig |
Config validation failed |
authcore.ErrInvalidTimezone |
Config.Timezone is nil |
authcore.ErrKeyManager |
key generation or loading failed |
auth/jwt package
| Error |
When |
jwt.ErrInvalidConfig |
jwt.Config validation failed |
jwt.ErrTokenExpired |
exp claim is in the past (beyond leeway) |
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 |
access token passed where refresh expected, or vice-versa |
jwt.ErrInvalidSubject |
subject passed to CreateTokens is not a UUID v7 |
auth/password package
| Error |
When |
password.ErrInvalidConfig |
password.Config validation failed |
password.ErrInvalidHash |
stored hash is malformed or not Argon2id PHC format |
password.ErrWeakPassword |
plaintext does not meet the built-in policy |
auth/email package
| Error |
Client-safe? |
When |
email.ErrInvalidEmail |
β Yes |
Address fails RFC 5321/5322 validation; errors.Unwrap gives the specific rule |
email.ErrDomainNoMX |
β Yes |
Domain exists but has no MX records (cannot receive email) |
email.ErrDomainUnresolvable |
β No |
DNS lookup failed; treat as soft failure, do not block the user |
auth/username package
| Error |
Client-safe? |
When |
username.ErrInvalidUsername |
β Yes |
Username fails a validation rule; errors.Unwrap gives the specific rule |
username.ErrInvalidConfig |
β No |
username.Config validation failed (startup error, treat as 500) |
Always use errors.Is for error inspection β errors may be wrapped:
claims, err := jwtMod.VerifyAccessToken(token)
if errors.Is(err, jwt.ErrTokenExpired) {
// prompt the client to refresh
}
FAQ
Do I need a database to use AuthCore?
No β AuthCore never touches your database. It hashes passwords, signs tokens, and validates input. You store hashes, usernames, and refresh-token hashes wherever your app already keeps data (Postgres, Redis, SQLite, even an in-memory map for a toy project).
What is a UUID v7 and why does `CreateTokens` require one?
UUID v7 is a 128-bit identifier whose first 48 bits are a millisecond Unix timestamp (RFC 9562 Β§5.7). That means UUID v7 values sort naturally by creation time β ideal as a database primary key and as a stable session identifier. AuthCore requires v7 for the sub claim so your sessions always sort chronologically.
Libraries that generate UUID v7 in Go: github.com/google/uuid (β₯ v1.6), github.com/gofrs/uuid.
Do I really need refresh token rotation?
Short answer: yes, if your refresh token lives longer than a few minutes. Rotation limits the blast radius of a stolen refresh token β once the legitimate client rotates, the stolen copy is rejected. Combined with storing only the hash of refresh tokens on the server, an attacker who dumps your database still cannot forge new sessions.
My access token fails verification in a distributed system β is clock skew the issue?
Yes. Different servers may have clocks that drift a few seconds apart, causing ErrTokenExpired on a brand-new token. Set ClockSkewLeeway in your JWT config:
cfg := jwt.DefaultConfig()
cfg.ClockSkewLeeway = 5 * time.Second
Keep it small (5β30 s). Larger values erode the security margin of short-lived tokens.
I'm getting `ErrKeyManager` on startup. What went wrong?
AuthCore could not read or create its key files. Check that:
KeysDir (default .authcore) is writable by the process.
- The directory is not a read-only filesystem (common in some container setups).
- Existing key files are not corrupted β delete
.authcore and let AuthCore regenerate them. Warning: regenerating keys invalidates every token currently in circulation.
Can I verify tokens issued before I rotated my signing key?
Yes. AuthCore embeds the kid (key ID) in every token header. When you add a new key pair, keep the old public key in the key store under its original kid. The verifier will select the right key automatically. See the Key Management section for the rotation workflow.
My existing password hashes were created with a different library. Can I migrate?
Yes. See Migrating from bcrypt / other libraries for the re-hash-on-next-login pattern. If your hashes are already in PHC Argon2id format ($argon2id$v=19$β¦), no migration is needed at all β pwdMod.Verify reads parameters from the stored hash.
Can I run AuthCore in Docker / Kubernetes?
Yes. Point KeysDir at a mounted secrets volume so keys survive container restarts and are shared across replicas:
cfg := authcore.DefaultConfig()
cfg.KeysDir = os.Getenv("AUTHCORE_KEYS_DIR") // e.g. /run/secrets/authcore
Pre-generate the key files once (a one-shot job that runs AuthCore against the volume), then mount them read-only into your app.
The `Hash` call is slower than expected in tests. Is that normal?
Yes β Argon2id deliberately takes ~100β300 ms and allocates 64 MiB of RAM per call. In tests, use a low-cost config to avoid slow suites:
pwd, _ := password.New(auth, password.Config{
Memory: 8 * 1024, // minimum allowed (8 MiB)
Iterations: 1,
Parallelism: 1,
})
Does AuthCore ship an HTTP server, middleware, or CSRF protection?
No β AuthCore gives you the primitives (hash, sign, verify, rotate) and stays framework-agnostic. See examples/fiber and examples/gin for wiring into a real HTTP stack, including protected-route middleware.
Coverage
π Sunburst coverage graph Β· click to expand

Each ring is a directory; each slice is a file. Greener wedges are better covered. Click through for the full per-line report on Codecov.
Roadmap
Shipped:
- β
Core library β key management, logger, clock, Provider interface
- β
auth/jwt β EdDSA token issuance, verification, rotation, timing-safe hash
- β
auth/password β Argon2id password hashing with PHC format
- β
auth/email β RFC 5321/5322 validation, normalization, DNS MX verification with cache
- β
auth/username β validation, normalization, reserved name blocklist
Planned (no hard ETA β subject to community input):
- π§
auth/apikey β opaque key generation with a pluggable store interface
- π§ Key rotation helpers β zero-downtime rotation via the
kid header
- π
auth/oauth β OAuth 2.0 / OIDC provider integration (larger scope, later)
Have an opinion on priority, or a use case we haven't thought about? Open a discussion β the roadmap bends toward real user needs.
API Stability
βΉοΈ Versioning policy β v1.x public API is frozen Β· click to expand
authcore follows Semantic Versioning.
v1.x (current) β the public API is frozen under semver guarantees. Breaking changes only ship in a new major version (v2.0.0). Minor releases add features; patch releases fix bugs or harden existing code paths without changing public shapes.
v1.2.0 shipped defence-in-depth hardenings: JWT TTL caps, kid header matching, Unicode NFC password normalisation, IDN email support, and a PEM file size cap. See CHANGELOG.md for the full release history.
Internal packages (internal/β¦) carry no compatibility guarantees at any version and must not be imported from outside the module.
Get Started Now
Ready to add secure auth to your Go app? Here's the 2-minute path:
- π¦ Install β
go get github.com/Jaro-c/authcore
- β‘ Copy the Quick Start above into your
main.go
- π§ͺ Run a module example end-to-end β
examples/jwt, examples/password, examples/fiber, examples/gin
- π Reference the full API on pkg.go.dev
β If AuthCore saved you from writing your own password hasher, please star the repo β it helps others find it.
β€οΈ Keeping it free and maintained takes time. If you or your company depend on AuthCore, consider sponsoring on GitHub β any amount funds continued security audits and new modules.
Contributing
Contributions are welcome! Please read the Contributing Guidelines and Code of Conduct before opening a pull request. Bug reports, feature ideas, and docs improvements are all valuable β open an issue or discussion any time.
Security
To report a vulnerability, please follow the Security Policy.
Do not open a public issue for security bugs β coordinated disclosure keeps users safe while a fix is prepared.
License
Released under the MIT License.