An API token is a long random string a client sends on every request in the Authorization: Bearer <token> header. Unlike a session cookie it works from scripts, mobile apps and other servers. Vukem's tokens are opaque: the token itself means nothing, the server looks it up. That makes them easy to revoke, which is the main difference from JWTs.
Opaque tokens (auth.Tokens) | JWT (auth/jwt) | |
|---|---|---|
| Checked by | a database lookup | a signature, no lookup |
| Revoke one token | mark its row revoked, effective immediately | wait for expiry, or keep a deny list |
| Best for | your own API and its clients, personal access tokens | tokens issued by an identity provider, many services |
How a token looks and is stored#
vk_<id>.<secret>vk_is an optional prefix (Tokens.Prefix) that lets secret scanners recognise leaked tokens.<id>finds the row;<secret>proves the caller knows the token. Both come fromcrypto/rand.- Only
SHA-256(secret)is stored, in theaccess_tokenstable. A database leak does not leak usable tokens, and the comparison is constant time. last_used_atis updated at most once a minute, so busy clients do not write on every request.- Revoking sets
revoked_at; the row stays so the user (and an audit) can see when a token was revoked. A revoked token is refused exactly like a wrong one: it pays for the same hash comparison first.Prunedeletes old revoked and expired rows, never a token that is still live.
Step by step#
The fastest path is the scaffold, which writes everything below as code you own:
vukem install:api # or: vukem new shop --api
vukem migrate # creates access_tokensTo understand (or hand-wire) what it does, follow these steps.
1. Create the token store and the guard
users := auth.NewORMProvider[models.User](db)
tokens := auth.NewTokens(db) // the access_tokens table
tokens.Prefix = "vk_" // pick a prefix specific to your app, e.g. "shop_"
apiGuard := auth.NewTokenGuard("api", users, tokens) // reads "Authorization: Bearer ..."The guard name ("api") is how handlers and middleware refer to it.
2. Let clients exchange a password for a token
type issueInput struct {
Email string `json:"email"`
Password string `json:"password"`
DeviceName string `json:"device_name"`
}
var issueSchema = z.Struct[issueInput](z.Shape{
"email": z.String().Trim().Email(),
"password": z.String().Min(1),
"device_name": z.String().Trim().Min(1).Max(100),
})
func (h *Tokens) Issue(c *web.Ctx) error {
in, err := web.Parse(c, issueSchema)
if err != nil {
return err // 422 with field errors
}
// Checks the password without starting a session.
u, err := h.a.Web.Validate(c, map[string]string{"email": strings.ToLower(in.Email), "password": in.Password})
if err != nil {
return err
}
plain, tok, err := h.a.Tokens.Create(c.Context(), u.AuthID(), in.DeviceName, []string{"*"}, 30*24*time.Hour)
if err != nil {
return err
}
// The plain token is shown once; it cannot be read back later.
return c.JSON(http.StatusCreated, map[string]any{"token": plain, "type": "Bearer", "expires_at": tok.ExpiresAt})
}3. Protect routes with the guard
issue := ratelimit.Middleware(limiters("tokens.issue", 5, time.Minute), ratelimit.ByIP)
perToken := ratelimit.Middleware(limiters("api.token", 60, time.Minute), ratelimit.ByToken)
r.Group("/api/v1", func(g *router.Group) {
g.Use(middleware.JSONOnly(), perToken)
g.Post("/tokens", h.Issue).Name("tokens.store").Use(issue) // password guessing is throttled
g.Group("", func(p *router.Group) {
p.Use(auth.Required(apiGuard)) // 401 problem+json without a valid token
p.Get("/me", h.Me).Name("me")
p.Delete("/tokens/current", h.Revoke).Name("tokens.destroy")
})
}).Name("api.v1.")limiters is app.RateLimiters(); see Rate limiting.
4. Read the user and the token in a handler
func (*Tokens) Me(c *web.Ctx) error {
u, err := auth.User[models.User](c, "api")
if err != nil {
return err
}
return c.JSON(http.StatusOK, u)
}
func (h *Tokens) Revoke(c *web.Ctx) error {
tok, ok := auth.CurrentToken(c, "api") // the token of this request
if !ok {
return auth.ErrUnauthenticated
}
if err := h.a.Tokens.Revoke(c.Context(), tok.ID); err != nil {
return err
}
return c.NoContent()
}5. Try it
curl -s -X POST localhost:3000/api/v1/tokens \
-H 'Content-Type: application/json' \
-d '{"email":"ada@example.com","password":"secret-password","device_name":"cli"}'
# {"token":"vk_....","type":"Bearer","expires_at":"..."}
curl -s localhost:3000/api/v1/me -H 'Authorization: Bearer vk_....'
curl -s -X DELETE localhost:3000/api/v1/tokens/current -H 'Authorization: Bearer vk_....'Abilities (scopes)#
Each token carries a list of abilities. "*" grants everything; anything else is a string you define, such as posts:read.
plain, _, err := tokens.Create(ctx, user.AuthID(), "reporting", []string{"posts:read"}, 90*24*time.Hour)// Route level: 403 unless the token grants every listed ability.
p.Get("/posts", posts.Index).Use(auth.Ability(apiGuard, "posts:read"))
// Handler level.
if !auth.TokenCan(c, "api", "posts:write") {
return errs.ErrForbidden
}Abilities limit what a token may do; they do not replace authorization of what the user may do. Check both.
A "My API tokens" page#
list, err := tokens.List(ctx, user.AuthID()) // newest first, revoked ones too (RevokedAt set), never the secrets
revoked, err := tokens.RevokeFor(ctx, user.AuthID(), c.Param("id"))
if err != nil {
return err
}
if !revoked {
return errs.ErrNotFound // not this user's live token: do not reveal that it exists
}
err = tokens.RevokeAll(ctx, user.AuthID()) // e.g. after a password changeRevoked tokens stay listed until you delete them. Run Prune from a scheduled task to keep the table small:
// Deletes tokens revoked or expired more than 90 days ago.
n, err := tokens.Prune(ctx, app.Now().AddDate(0, 0, -90))Upgrading a table created before v0.26.0#
Older install:auth scaffolds created access_tokens without revoked_at. Until the column exists every token is refused with 503 Service Unavailable (the lookup fails, and a failed check denies), revoking deletes the row instead (the safe side), and one error naming auth.TokensRevokedAtMigration is logged. The same 503 answers any other store failure, such as the database being down; only a missing, wrong, expired or revoked token gets 401. Add the shipped migration to your list; it skips a table that already has the column:
func m20261101000000AccessTokensRevokedAt() schema.Migration {
return auth.TokensRevokedAtMigration("20261101000000_access_tokens_revoked_at")
}Register it in your migrations list like the others, then run vukem migrate. A project created with the current install:auth already has the column; the migration is then a no-op. The examples/* apps in the repository ship this migration.
Testing#
plain, _, err := tokens.Create(ctx, user.AuthID(), "test", []string{"*"}, time.Hour)
if err != nil {
t.Fatal(err)
}
app.Client(t).WithToken(plain).GetJSON("/api/v1/me").AssertOK()Pitfalls#
ttl0 never expires.Create(ctx, id, name, abilities, 0)returns a token valid until revoked. Pass a lifetime unless you mean it (the scaffold uses 30 days).- Session guard first on mixed routes.
auth.Required(webGuard, apiGuard), not the reverse:Requiredstops at a guard that fails with 503, so with the token guard first a browser with a valid session and a strayBearerheader gets 503 while the token store is down. - Revoked tokens are listed.
Listreturns them withRevokedAtset; filter them out (or show them greyed out) on a "My API tokens" page. Revokehas no owner check. Use it only for the token of the current request (auth.CurrentToken). When the id comes from a form or URL, useRevokeFor, or one user can revoke another's tokens by guessing ids.- The plain token is gone after
Create. Show it once and tell the user to copy it; there is no way to read it back. - Changing
Prefixis safe. Tokens issued without a prefix, or under an earlier one, keep working. - Put
api.Idempotencyafterauth.Required. Its scope is then the user, not the token string; see Idempotency keys.
Security notes#
- Serve the API over HTTPS only: a bearer token is a password in a header.
- Throttle the issuing endpoint by IP (the scaffold allows 5 per minute) and the API by token (
ratelimit.ByTokenhashes the token before using it as a key). - Add a secret-scanning pattern for your prefix, for example
vk_[A-Z2-7]{26}\.[A-Z2-7]{26}, to GitHub push protection or gitleaks. - Rejected tokens are logged as security events and never written to logs in clear.
- Revoke every token on password reset; the
install:authreset flow already does, withTokens.RevokeAllinside the reset transaction.