Skip to content

Security

API tokens

Issue, check, list and revoke opaque bearer tokens for API clients with auth.Tokens and the token guard, step by step.

Last updated:

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 bya database lookupa signature, no lookup
Revoke one tokenmark its row revoked, effective immediatelywait for expiry, or keep a deny list
Best foryour own API and its clients, personal access tokenstokens issued by an identity provider, many services

How a token looks and is stored#

Text
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 from crypto/rand.
  • Only SHA-256(secret) is stored, in the access_tokens table. A database leak does not leak usable tokens, and the comparison is constant time.
  • last_used_at is 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. Prune deletes 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:

Terminal
vukem install:api   # or: vukem new shop --api
vukem migrate       # creates access_tokens

To understand (or hand-wire) what it does, follow these steps.

1. Create the token store and the guard

app/accounts/accounts.go
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

app/controllers/apiv1/tokens.go
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

routes/api.go
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

Go
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

Terminal
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.

Go
plain, _, err := tokens.Create(ctx, user.AuthID(), "reporting", []string{"posts:read"}, 90*24*time.Hour)
Go
// 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#

Go
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 change

Revoked tokens stay listed until you delete them. Run Prune from a scheduled task to keep the table small:

Go
// 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:

database/migrations/20261101000000_access_tokens_revoked_at.go
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#

Go
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#

  • ttl 0 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: Required stops at a guard that fails with 503, so with the token guard first a browser with a valid session and a stray Bearer header gets 503 while the token store is down.
  • Revoked tokens are listed. List returns them with RevokedAt set; filter them out (or show them greyed out) on a "My API tokens" page.
  • Revoke has no owner check. Use it only for the token of the current request (auth.CurrentToken). When the id comes from a form or URL, use RevokeFor, 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 Prefix is safe. Tokens issued without a prefix, or under an earlier one, keep working.
  • Put api.Idempotency after auth.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.ByToken hashes 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:auth reset flow already does, with Tokens.RevokeAll inside the reset transaction.