IV Users and Access

Keep Users Signed In

A customer signs in once and stays signed in for weeks, on every device they use. The client does it with the token the gateway hands out; you decide how long a sign-in lasts. A script or a partner that calls the shop gets a token of its own.

§What the client does

Whatever a request is authenticated with, its response carries a token:

GET /identity/ HTTP/1.1
host: api.store.example
authorization: Basic Ym9iOnNlY3JldCMxMjM0
200 OK
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...
cache-control: no-store

The token arrives in the authorization response header, in the form the client sends back:

GET /store/orders/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...

From time to time a response to such a request carries an authorization header again, with a newer token. The whole rule for a client is: whenever a response carries an authorization header, discard the token you hold and keep the new one.

  • A token is opaque: the client stores it and sends it, and reads nothing from it.
  • A page in a browser can read the header on cross-origin responses; nothing has to be configured.
  • A response that carries a token is cache-control: no-store, whatever the route declares.
  • Signing out is deleting the token.

A token that is too old, damaged or issued for another domain leaves the request without an identity:

GET /store/orders/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...expired
401 Unauthorized

The client then asks the user to sign in again.

§Choose how long a sign-in lasts

# context.toa.yaml
configuration:
  identity.tokens:
    lifetime: 2592000 # seconds; 30 days
    refresh: 600      # seconds; 10 minutes

The values shown are the defaults.

  • lifetime is how long a client may stay away. A client that makes a request at least once in that time stays signed in indefinitely; one that stays away longer signs in again.
  • refresh is how old a token gets before the next response replaces it. It is also how long a change takes to reach a signed-in client: a role granted or revoked, a changed password, a ban each take effect when the token is next replaced, at most refresh seconds later. A smaller value shortens that delay and makes the gateway look the identity up more often. A client can also apply a change now.

§Apply a change now

A client that knows its roles changed — a realtime event told it a role was granted — does not have to wait for refresh. It asks for a new token:

POST /identity/tokens/refresh/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
accept: application/yaml
200 OK
authorization: Token eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...
cache-control: no-store

id: 2428c31ecb6e4a51a24ef52f0c4181b9
roles:
  - store:editor

The new token carries the roles the identity holds now, and the body is what GET /identity/ answers. The client keeps the token, as from any other response.

  • A token issued before a sign-out everywhere is answered 401 here at once, without waiting for refresh.
  • A custom token is answered 403: it is never replaced.

§Sign a user out everywhere

A single token cannot be taken back. All tokens of an identity stop working when:

A token issued before that keeps working for up to refresh seconds and is then answered 401. To sign out one device, the device deletes its token.

§Test it

The steps are the ones toa create wrote, which Write a Test explains. The scenario signs a customer up at the sign-up route.

# features/accounts.feature
Feature: Accounts

  Scenario: Continuing with the token
    When the request is sent:
      """
      POST /accounts/ HTTP/1.1
      authorization: Basic #{{ basic | set bob.basic }}
      content-type: application/yaml
      accept: application/yaml

      name: Bob
      """
    Then the response is received:
      """
      201 Created
      authorization: Token ${{ bob.token }}

      id: ${{ bob.id }}
      """
    When the request is sent:
      """
      GET /identity/ HTTP/1.1
      authorization: Token ${{ bob.token }}
      accept: application/yaml
      """
    Then the response is received:
      """
      200 OK

      id: ${{ bob.id }}
      """
    When the request is sent:
      """
      GET /identity/ HTTP/1.1
      authorization: Token not-a-token
      accept: application/yaml
      """
    Then the response is received:
      """
      401 Unauthorized
      """
$ npm run restart
$ npm run features

§Keys

Tokens are made with a key that you provide. The Context toa create wrote names one, and npm run env generates its value on your machine:

# context.toa.yaml
configuration:
  identity.tokens:
    keys:
      - id: key0
        key: $IDENTITY_TOKENS_ENCRYPTION_KEY0

Generate a key for a deployment:

$ npx toa key

Each entry has an id of your choice and a key, which is a secret: write a $NAME reference and provide the value as Configuration and Secrets describe.

The first entry issues new tokens. Tokens made with any entry of the list are accepted.

§Replace a key

  1. Add the new key after the current one and deploy.

    keys:
      - id: key0
        key: $IDENTITY_TOKENS_ENCRYPTION_KEY0
      - id: 2026q4
        key: $IDENTITY_TOKENS_KEY_2026Q4
  2. Move the new key to the first position and deploy. New tokens are made with it; old ones are still accepted.

    keys:
      - id: 2026q4
        key: $IDENTITY_TOKENS_KEY_2026Q4
      - id: key0
        key: $IDENTITY_TOKENS_ENCRYPTION_KEY0
  3. Remove the old key lifetime after step 2 at the earliest, 30 days by default. Waiting for refresh is not enough: a client that was away still holds a token made with the old key.

Removing a key signs out everyone whose token was made with it. Never change the id of a key in use.

§Keys of older versions

An entry may carry format: paseto. Such a key only accepts tokens issued by older versions of the runtime, and each is replaced with a current one on its first use. Keep the entry until those tokens have expired, then delete it. At least one entry without format: paseto is required. toa key --format paseto generates such a key.

§Give a script a token

A nightly export, a CI job or a partner integration needs a credential that is not a person’s password and does not change from response to response. An identity issues a custom token for itself:

POST /identity/tokens/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
content-type: application/yaml
accept: application/yaml

label: Nightly export
lifetime: 3600
201 Created

kid: 5b0c2f3d1a8e4c7f9d6b2a1e0f3c4d5e
exp: 1759700000000
token: eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...

token is sent as authorization: Token … like any other. exp is when it expires, in milliseconds; kid is what revokes it.

A custom token is never replaced: responses carry no new one, and it works unchanged until it expires. Replacing the keys of identity.tokens.keys does not affect it.

PropertyMeaning
labelRequired. A name of 1 to 64 characters, shown when tokens are listed.
lifetimeSeconds the token is valid. Defaults to the configured lifetime. 0 never expires; avoid it in production.
scopesRoles the token acts with, in place of the identity’s own.
permissionsPaths and methods the token may be used at.

§Limit it to some roles

label: Catalog reader
lifetime: 86400
scopes: [store:catalog:read]

The token acts as an identity whose only role is store:catalog:read. A token cannot hold more than its owner: each scope must be one that a role of the identity covers, or no token is issued:

422 Unprocessable Entity

code: INACCESSIBLE_SCOPE

§Limit it to some paths

label: Order export
lifetime: 86400
permissions:
  /store/orders/2428c31ecb6e4a51a24ef52f0c4181b9/: [GET]
  /pictures/**: ['*']

Keys are glob patterns matched against the request path, without the query string. Values are the methods allowed there; * is any method. Anywhere else the token is refused, even where its owner would be admitted:

POST /store/orders/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
403 Forbidden

scopes and permissions only narrow: the token still has to pass the route’s own access directives as its identity.

§Act for another identity

An identity that holds the role system:identity:tokens may issue a token for any identity, by putting that identity’s id into the path:

POST /identity/tokens/e8e4f9c2a68d419b861403d71fabc915/ HTTP/1.1
host: api.store.example
authorization: Token <a token of an identity with system:identity:tokens>
content-type: application/yaml

label: Support session
lifetime: 600

The token that comes back is a token of e8e4f9c2a68d419b861403d71fabc915. This is how a support tool acts as a customer. Keep the lifetime short and the role rare.

§List and revoke custom tokens

GET /identity/keys/2428c31ecb6e4a51a24ef52f0c4181b9/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
accept: application/yaml
- id: 5b0c2f3d1a8e4c7f9d6b2a1e0f3c4d5e
  label: Nightly export
  expires: 1759700000000
  CREATED: 1759696400000

Newest first; id is the kid of the token. A revoked one also carries revokedAt. The token itself is never returned again.

DELETE /identity/keys/2428c31ecb6e4a51a24ef52f0c4181b9/5b0c2f3d1a8e4c7f9d6b2a1e0f3c4d5e/ HTTP/1.1
host: api.store.example
authorization: Token eyJhbGciOiJkaXIi...
204 No Content

Both requests need the identity itself or the role system:identity:keys.

All custom tokens of an identity are revoked without asking when the identity is banned, when its password is changed, and when any of its roles is revoked. After a role is revoked, issue the tokens again; they carry the roles that are left.

A revoked token stops working within cache.ttl:

# context.toa.yaml
configuration:
  identity.tokens:
    cache:
      max: 1024   # custom tokens remembered per process
      ttl: 600000 # milliseconds; 10 minutes

The values shown are the defaults. Give both when you change either.

§Test it

# features/tokens.feature
Feature: Script tokens

  Scenario: A token limited to reading orders
    When the request is sent:
      """
      POST /accounts/ HTTP/1.1
      authorization: Basic #{{ basic }}
      content-type: application/yaml
      accept: application/yaml

      name: Bob
      """
    Then the response is received:
      """
      201 Created
      authorization: Token ${{ bob.token }}

      id: ${{ bob.id }}
      """
    When the request is sent:
      """
      POST /identity/tokens/${{ bob.id }}/ HTTP/1.1
      authorization: Token ${{ bob.token }}
      content-type: application/yaml
      accept: application/yaml

      label: Order export
      lifetime: 60
      permissions:
        /store/orders/${{ bob.id }}/: [GET]
      """
    Then the response is received:
      """
      201 Created

      token: ${{ export.token }}
      """
    When the request is sent:
      """
      GET /store/orders/${{ bob.id }}/ HTTP/1.1
      authorization: Token ${{ export.token }}
      """
    Then the response is received:
      """
      200 OK
      """
    When the request is sent:
      """
      POST /store/orders/${{ bob.id }}/ HTTP/1.1
      authorization: Token ${{ export.token }}
      content-type: application/yaml

      items: []
      total: 0
      """
    Then the response is received:
      """
      403 Forbidden
      """

The routes of /store/orders/:customer/ are declared in Restrict Who Can Call a Route, and POST /accounts/ in Sign Users Up and In.

§Tokens sent as Bearer

A token the gateway issued is also accepted under the Bearer scheme:

authorization: Bearer eyJhbGciOiJkaXIiLCJlbmMiOiJBMjU2R0NNIi...

This is how other applications present theirs. A client of your own uses Token.

§Reference

§identity.tokens

KeyMeaningDefault
keysKeys, the first one issues. Each: id, key (secret), optional format (jwe or paseto).required
lifetimeSeconds a token is valid.2592000
refreshSeconds before a token is replaced on use.600
cache.maxCustom tokens remembered per process.1024
cache.ttlMilliseconds a custom token is remembered.600000

§What the client sees

SituationReply
A token that cannot be read, or whose key is no longer configured401
A token past its lifetime401
A token issued under another authority401
A token of a banned identity or issued before a password change, after refresh; a revoked custom token, after cache.ttl401
A custom token used outside its permissions403
A custom token whose scopes do not cover the route’s role403
A custom token presented to POST /identity/tokens/refresh/403

How tokens are built and why is in the model.

Search
Search the documentation