Skip to content
Quickstart

Quickstart

This walks the shortest working path with the Go library as it ships today: install it, create an operator, account, and user, issue their tokens, and verify a signed request server-side against the allowlist. Everything runs in one process against a local listener, so you can see the full loop before splitting it across a real client and server.

Prerequisites

  • Go 1.26 or newer.

Install

go get valiss.dev/valiss

The core module carries token issue and verify, the request Verifier, and the allowlist. The contrib/httpauth package (pulled in by the same module) adds the net/http middleware and client transport this example uses.

The whole loop

Drop this into main.go and run go run .. It issues the three-level key chain, stands up an HTTP server that trusts only the operator public key, and makes one signed request through it.

package main

import (
	"fmt"
	"io"
	"log"
	"net/http"
	"net/http/httptest"
	"time"

	"github.com/nats-io/nkeys"

	"valiss.dev/valiss"
	"valiss.dev/valiss/contrib/httpauth"
	"valiss.dev/valiss/creds"
)

func main() {
	// 1. Operator: the trust anchor. Its public key is the one thing a
	// server pins; its seed never leaves the issuer.
	operator, err := nkeys.CreateOperator()
	must(err)
	operatorPub, err := operator.PublicKey()
	must(err)

	// 2. Account: one per tenant. The operator signs its token. The HTTP
	// extension bounds what the tenant may call; the contrib transports
	// enforce it fail closed, so every token in the chain must carry it.
	account, err := nkeys.CreateAccount()
	must(err)
	accountPub, err := account.PublicKey()
	must(err)
	accountToken, err := valiss.IssueAccount(operator, accountPub,
		valiss.WithName("acme"),
		valiss.WithExtension(httpauth.Ext{Methods: []string{"GET"}, Paths: []string{"/v1/*"}}),
		valiss.WithTTL(time.Hour),
	)
	must(err)

	// 3. User: one per end user or service. The account signs its token,
	// attenuating no further than it holds.
	user, err := nkeys.CreateUser()
	must(err)
	userPub, err := user.PublicKey()
	must(err)
	userSeed, err := user.Seed()
	must(err)
	userToken, err := valiss.IssueUser(account, userPub,
		valiss.WithName("alice"),
		valiss.WithExtension(httpauth.Ext{Methods: []string{"GET"}, Paths: []string{"/v1/*"}}),
		valiss.WithTTL(time.Hour),
	)
	must(err)

	// The allowlist admits the account token by its id (jti). Removing that
	// id revokes the whole tenant, every user under it, without touching a
	// single key.
	acctClaims, err := valiss.VerifyAccount(accountToken, operatorPub)
	must(err)
	allowlist := valiss.NewStaticAllowlist(acctClaims.ID)

	// Server: the operator public key plus the allowlist are all it needs.
	// It never sees a seed. The middleware verifies the chain and injects the
	// identity; the handler reads it.
	mux := http.NewServeMux()
	mux.HandleFunc("/v1/whoami", func(w http.ResponseWriter, r *http.Request) {
		id, _ := valiss.IdentityFromContext(r.Context())
		fmt.Fprintf(w, "verified tenant %q, user %q\n", id.Account.Name, id.User.Name)
	})
	verifier := valiss.NewVerifier(operatorPub, allowlist)
	server := httptest.NewServer(httpauth.NewMiddleware(verifier)(mux))
	defer server.Close()

	// Client: its creds are the token chain plus the user seed that signs
	// each request. The transport signs transparently.
	clientCreds := creds.Creds{
		AccountToken: accountToken,
		UserToken:    userToken,
		Seed:         userSeed,
	}
	transport, err := httpauth.NewTransport(clientCreds, nil)
	must(err)
	client := &http.Client{Transport: transport}

	resp, err := client.Get(server.URL + "/v1/whoami")
	must(err)
	body, _ := io.ReadAll(resp.Body)
	resp.Body.Close()
	fmt.Printf("%s: %s", resp.Status, body)
}

func must(err error) {
	if err != nil {
		log.Fatal(err)
	}
}

Running it prints:

200 OK: verified tenant "acme", user "alice"

What just happened

  • The server was configured with only the operator public key and the allowlist. It holds no seeds and made no network call to authenticate the request.
  • The client signed the request with the user seed. The middleware walked the chain (operator signed the account token, the account signed the user token), checked the signature over the request, and confirmed the account token’s id is on the allowlist.
  • IdentityFromContext handed the handler the verified tenant and user. Reads after that point trust the identity without re-checking anything.

Try breaking it to see the guarantees hold. Drop userToken and Seed from the creds and the server answers 401. Request POST /v1/whoami or a path outside /v1/* and it answers 403, because the request falls outside the account’s HTTP extension.

Revoking

Revocation is an allowlist edit, not a key operation. To cut off the tenant, remove its account token id:

// remainingIDs is your tenant list minus this one, re-read from the allowlist
// file you maintain today.
allowlist.Set(remainingIDs)
// allowlist.Set(nil) // the nuke: revokes every tenant at once

The next request from that tenant, and from every user under it, fails verification. StaticAllowlist is in-memory; production servers load ids from a file with valiss.LoadAllowlistFile. That call reads the file once and installs no watcher, so the caller drives reloads: a file watch, a SIGHUP handler, or a poll interval re-reads the file and applies it with Set on the retained allowlist. Validate the new set before Set, because an empty file revokes every tenant. The Go guide shows the full pattern.

From here to production

Note

This walkthrough generates the signing seeds in-process to keep the example self-contained. A real deployment never does that: the operator and account seeds live in a secrets manager and never reach the server, which only ever holds the operator public key and the allowlist. Treat the in-process key generation here as a teaching device, not a template for production issuance.

This example issues keys in-process for clarity. In a real deployment the signing seeds live in a secrets manager, not the server. The examples/minter tool in the Go repository issues credentials from a manifest of public keys, resolving seeds from the environment, and writes a credentials file the client loads with creds.Load. The server side is unchanged: it still pins the operator public key and consults the allowlist.

The valiss CLI (early development) will be the issuer-side counterpart to this in-process issuance: keeping operator keys and tokens in an encrypted per-operator store and running issuance and creds export from there, so an operator issues credentials without writing Go. Its command tree is designed but not yet runnable (every command is currently a stub), so today that issuer-side issuance is the library Issue* calls above or examples/minter.

Next steps

  • Entities and Tokens: the model behind the three-level chain.
  • Allowlist: revocation in depth.
  • Extensions: the typed authorization claims the HTTP extension above is one instance of.
  • Rotation: epochs and operator tokens for rotating or mass-revoking a whole trust domain.
  • Go guide: gRPC wiring, Gin and Echo, bearer tokens, and the rest of the library surface.
  • Verifying credentials offline: how a receiver in any language checks a valiss credential.