Skip to content

Refresh tokens ​

This page explains how a client obtains a refresh token and uses it to renew access tokens, and what wicket v0.1.4 actually does and does not do regarding rotation, expiration, and so on. The latter directly affects your security design, so please read the whole page.

Two conditions for getting a refresh token ​

  1. The client sets AllowOfflineAccess = true;
  2. The scope of the authorization request includes offline_access.
go
web.AllowOfflineAccess = true
web.AllowedScopes = []string{"openid", "profile", "orders.read"} // no need to list offline_access here

offline_access is a built-in scope. You don't need to define it in an IdentityResource or ApiScope; the discovery document's scopes_supported lists it automatically. Observed results:

CaseResult
AllowOfflineAccess = true, request includes offline_accessToken response contains refresh_token
AllowOfflineAccess = true, request does not include offline_accessrefresh_token is an empty string
AllowOfflineAccess = false, request includes offline_accessRejected by the authorization endpoint (invalid_scope, redirected to the error page)

Refresh token issuance has been tested for both the authorization code and CIBA flows. The client credentials flow has no user, so do not enable AllowOfflineAccess for it.

Exchange a refresh token for new tokens ​

bash
curl -s -u web-app:web-secret \
  -d grant_type=refresh_token \
  -d refresh_token=$REFRESH_TOKEN \
  http://localhost:5000/connect/token

Observed response (truncated):

json
{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtp...",
  "expires_in": 3600,
  "id_token": "eyJhbGciOiJSUzI1NiIsImtp...",
  "refresh_token": "2AD2B6B67EB04056098359C8...",
  "scope": "openid orders.read offline_access",
  "token_type": "Bearer"
}
  • When the original request included openid, refreshing also returns a new id_token.
  • The scope parameter in refresh requests is ignored in v0.1.4: in testing, refreshing with scope=openid still returned all of the original scopes.
  • An invalid or nonexistent refresh token returns invalid_grant.

Refresh token fields on Client ​

Fields in storage/models/client.go and their NewClient() defaults:

FieldNewClient() defaultEffective in v0.1.4?
AllowOfflineAccessfalseYes
UpdateAccessTokenClaimsOnRefreshfalseYes (see below)
RefreshTokenUsagewicket.TokenUsageOneTimeOnlyNo
RefreshTokenExpirationwicket.TokenExpirationAbsoluteNo
AbsoluteRefreshTokenLifetime2592000 (30 days)No
SlidingRefreshTokenLifetime1296000 (15 days)No

Use NewClient() rather than a literal

The zero values of TokenUsage and TokenExpiration are TokenUsageOneTimeOnly and TokenExpirationSliding respectively. Creating a client with a models.Client{} literal gives you defaults different from NewClient() (and also misses Enabled = true and others). Always start from NewClient().

UpdateAccessTokenClaimsOnRefresh:

  • false (default): reuses the access token contents saved at the previous issuance, updates only the issue time and lifetime, and re-signs. Changes to user profile data are not reflected in the new token.
  • true: regenerates the access token claims from the user principal saved in the refresh token.

What v0.1.4 implements (important) ​

v0.1.4 does not yet implement refresh token expiration or rotation policies. Do not rely on the fields marked "No" in the table above. Specifically:

  1. No rotation: refreshing returns the same refresh token; RefreshTokenUsage = OneTimeOnly does not make it one-time use.
  2. No expiration: the issue time and lifetime are not recorded when the token is saved, nor checked during validation; AbsoluteRefreshTokenLifetime and SlidingRefreshTokenLifetime play no part.
  3. Not tied to sessions: signing the user out does not invalidate refresh tokens.

Other refresh token security policies are also still being completed. This page will be updated when a fixed version is released.

Recommendations until a fixed version is released

  • Enable AllowOfflineAccess only for confidential clients that genuinely need it and can keep their secret safe; configure each client's AllowedScopes with least privilege.
  • Add a lifetime to refresh tokens using the method below.
  • To revoke, call your RefreshTokenStore.RemoveRefreshToken directly (wicket does not register a /connect/revocation endpoint; requests to that path fall through to the host handler).

Storage port: versioned updates ​

For custom persistence, inject a storage.RefreshTokenStore with cfg.SetRefreshTokenStore(store). wicket relies on the following semantics (for the full contract, see Storage ports):

MethodRequirement
StoreRefreshToken(ctx, handle, token)Insert only. If handle already exists, return storage.ErrDuplicateHandle without modifying the existing record; the stored copy has version 1, which is written back to token.Version
GetRefreshToken(ctx, handle)Returns an independent copy; returns storage.ErrNotFound when not found (the token endpoint responds with invalid_grant based on this)
UpdateRefreshToken(ctx, handle, token, expectedVersion)Optimistic concurrency: writes only if the stored version equals expectedVersion; the new version is expectedVersion + 1 and is written back; otherwise returns storage.ErrVersionConflict without modifying anything
RemoveRefreshToken(ctx, handle)Delete
RemoveExpired(ctx, cutoff)Deletes records where "CreationTime + Lifetime is earlier than cutoff" and returns the number deleted; only reclaims space and plays no part in validity checks

Every refresh calls UpdateRefreshToken (in testing, the version went from 1 to 2). When two requests refresh the same token concurrently, the one that writes later gets ErrVersionConflict, which the token endpoint converts to invalid_grant. A database implementation is typically UPDATE ... SET version = version + 1 WHERE handle = ? AND version = ?, followed by a check of the affected row count.

Don't schedule RemoveExpired until you add a lifetime

Because v0.1.4 does not record the issue time and lifetime for refresh tokens, RemoveExpired(ctx, time.Now()) treats all of these records as expired and deletes every refresh token. wicket does not start background goroutines of its own; cleanup jobs are scheduled by the host. Until you add a lifetime (next section), do not schedule this cleanup job for refresh tokens.

Adding a lifetime: wrap the store ​

Wrap any RefreshTokenStore to record the issue time and lifetime on write and reject expired tokens on read. The code below has been compiled and run in the experimental program: with a lifetime of 3 seconds, the first refresh succeeded, and refreshing again 4 seconds later returned invalid_grant, with the record deleted at the same time.

go
// expiringRefreshTokens adds "issue time + absolute lifetime" to any RefreshTokenStore.
type expiringRefreshTokens struct {
	storage.RefreshTokenStore
	ttl time.Duration
	now func() time.Time
}

func (s *expiringRefreshTokens) StoreRefreshToken(ctx context.Context, handle string, t *models.RefreshToken) error {
	if t.CreationTime.IsZero() {
		t.CreationTime = s.now().UTC()
	}
	if t.Lifetime == 0 {
		t.Lifetime = int(s.ttl / time.Second)
	}
	return s.RefreshTokenStore.StoreRefreshToken(ctx, handle, t)
}

func (s *expiringRefreshTokens) GetRefreshToken(ctx context.Context, handle string) (*models.RefreshToken, error) {
	t, err := s.RefreshTokenStore.GetRefreshToken(ctx, handle)
	if err != nil {
		return nil, err
	}
	if s.now().After(t.CreationTime.Add(time.Duration(t.Lifetime) * time.Second)) {
		_ = s.RefreshTokenStore.RemoveRefreshToken(ctx, handle)
		return nil, storage.ErrNotFound // the token endpoint returns invalid_grant based on this
	}
	return t, nil
}

Injection (replace inner with your database implementation; for development you can use NewRefreshTokenStore() from the storage/impl package):

go
cfg.SetRefreshTokenStore(&expiringRefreshTokens{
	RefreshTokenStore: inner,
	ttl:               30 * 24 * time.Hour,
	now:               time.Now,
})

Once CreationTime and Lifetime are filled in, RemoveExpired also gives correct results, and the host can safely call it on a schedule.

Released under the Apache License 2.0 · Documentation © 2026 Wicket authors