#Auth pipeline: JWT
#Purpose
Require a valid JWT on every endpoint of an API definition. auth is a built-in pipeline (§4) whose lock is provided and signed by the platform; you enable it and pass parameters (mode, roles, optionally iss, aud, jwks-uri) (§4.1, Decision 44). Declare it once in _definition.md so it is inherited by every version and feature (§3), and restrict roles per feature where needed.
The example feature is the admin create-user endpoint from §11.4. Under v1.7 it no longer sets a password: it creates an unverified user without a password and sends an invite (Decision 69).
#Natural-language source example
api/users/_definition.md
# users
User management API. Allowed providers: main and enterprise; default is main.
All endpoints require JWT authentication.
api/users/v2/create-user/create-user.md (route POST /users/v2/create-user)
# Create user
POST. Only admin may use it, at most 30 requests per minute.
The request body must contain email (valid email) and name (1 to 50 characters).
Create the user with user.register (no password, unverified), then send an invitation with user.invite; on success return 201 with the new user's id, email and name, plus Location.
If the email is already taken return 409 EMAIL_TAKEN; validation failure returns 422.
#Compiled lock md example
api/users/_definition.lock.md (sections only; frontmatter per §2A.1 with kind: api, scope: definition)
## Providers
- allowlist: [main, enterprise]
- default: main
## Pipelines
### p1 auth
- mode: jwt
## Defaults
- timeout: 10s
- body: 1MB
With no iss/aud/jwks-uri given, the platform defaults apply: iss = https://auth.{platform-domain}/{project}, aud = {project}, jwks-uri = {iss}/.well-known/jwks.json (§4.1).
api/users/v2/create-user/create-user.lock.md
---
lock-version: 1
kind: api
scope: feature
id: users/v2/create-user
source: api/users/v2/create-user/create-user.md
source-hash: sha256:7f02...aa
source-type: natural
dependencies:
- { id: user/methods/invite, kind: method, hash: sha256:<...> }
- { id: user/methods/register, kind: method, hash: sha256:c3e1...90 }
- { id: users/_definition, kind: api, hash: sha256:11ab...3c }
- { id: users/v2/_version, kind: api, hash: sha256:0d9e...51 }
vibe-version: 1.0.0-rc.4
status: approved
approval-mode: manual
approved-by: [jack@example, mei@example]
approved-at: 2026-10-14T08:10:00+08:00
approval-ref: pr#122
approved-commit: a1b2c122
security-items: [{ item: roles, risk: high, reason: "Only admin may create users", approved-by: [jack@example, mei@example] }]
signature: MEUCIG...
---
## Route
- method: POST
- path: /users/v2/create-user
## Access
- public: false
- public-writes: false
## Pipelines
### p1 auth
- mode: jwt
- roles: [admin]
### p2 rate-limit
- per-minute: 30
- key: user
## Limits
- provider: main
- timeout: 10s
- body: 1MB
## Request
| field | type | rules | required |
| --- | --- | --- | --- |
| body.email | string | format: email | yes |
| body.name | string | min: 1, max: 50 | yes |
## Response
| status | schema | headers |
| --- | --- | --- |
| 201 | { id, email, name } | Location |
## Transform
- in: { email: req.body.email, name: req.body.name }
- out: { id: u.id, email: u.email, name: u.name }
## Steps
### s1 method
- op: method
- target: user.register
- args: transform.in
- out: u
- source-lines: 4-4
### s2 method
- op: method
- target: user.invite
- args: { id: u.id }
- out: (none)
- source-lines: 4-4
### s3 respond
- op: respond
- status: 201
- value: transform.out
- location: /users/v2/{u.id}
- source-lines: 4-4
## Errors
| status | code | from |
| --- | --- | --- |
| 409 | EMAIL_TAKEN | EMAIL_TAKEN |
| 422 | VALIDATION_FAILED | VALIDATION_FAILED |
⚠️ The spec's own §11.4 lock shows only the
user.registerstep even though its source says "then send an invitation with user.invite". A lock that omits the invite step would contain less than the source says; this page addss2so the lock matches the source and Decision 69. Theuser.inviteinput ({ id }) and thekey: userrate-limit key are illustrative; see Spec gaps.
#Invite flow (Decision 69, §12.11.9)
- Admin calls create-user →
user.registercreates the user with no password andemail_verified_at = null(thepasswordcolumn is nullable since migration0004, §12.11.1). user.invitegenerates a token withrandom-token, writes its hash tousers.email_verify_hash, setsemail_verify_expires_atto 72 hours later andemail_verify_purpose = invite, then enqueues an email throughemail-outbox.enqueuewithpurpose: invite(fixed in the lock) and theinvitetemplate.- The user opens the invite link, sets a password on the same set-password page as self-registration, which posts to
POST /auth/v1/{slug}/verify-email→user.complete-verification. One UPDATE setspasswordandemail_verified_attogether; all refresh-token families are then revoked (Decision 67). See user-register.md. - Until then the user has no password and is unverified, so login is impossible (Decision 55).
- Re-inviting overwrites the token hash and expiry, so the old invite stops working immediately. An expired, unaccepted invite is anonymized and soft-deleted by the same cleanup job as unverified self-registrations (§12.11.4).
- A public self-registration for an email with an unexpired invite leaves the invite untouched; the registrant gets only the neutral "already have an account" notice (§12.11.4).
#Security considerations
- Default deny: every endpoint goes through
authunless explicitly removed; removing it (-auth) must be paired withpublic: true(§3, §4, §10.3). - Ordering:
authruns first (must-run: first, §4.4). - JWT validation (§4.1, Decision 44):
issmust match exactly;audmust contain the configured value;jwks-urimust be https; JWKS cached 10 minutes, unknownkidtriggers a re-fetch (at most once per 60 s per issuer), stale cache usable for at most 1 hour, then all tokens get 401; clock skew 60 s onexp/nbf/iat; alg only ES256 or RS256 (RSA ≥ 2048 bit),none/HS* forbidden,kidrequired; tokens whoseexpis more than 1 hour afteriatare rejected. - Changing
iss,audorjwks-uri(for example to use an external IdP) is a high-risk security item (§2A.5). - Roles:
roles-claimdefaults toroles;roles: []is an error (§4). Adding, changing or relaxing role checks is high risk (§2A.5). - Context:
authinjectsctx.tenant_idandctx.user; logic cannot modify them (§4, §12.5). - No admin-set passwords: admins never set or see a user's password; the user sets it via the invite link (Decision 69). The admin path may return 409
EMAIL_TAKENbecause it is authenticated and admin-only; the public path never may (§12.11). - Errors: 401 with
WWW-Authenticate(RFC 6750) for missing/invalid tokens, 403 for insufficient role (§8). - Tokens are never logged (§9).
#Spec gaps
- §11.4's lock omits the
user.invitestep that its own source describes (lock contains less than source). This page adds it. - No lock is given for
user.register(admin) oruser.invite; their## Signature(e.g. the invite input) is not specified. - §11.4
p2 rate-limithas nokey, but §4.3 makeskeyrequired.key: userhere is an assumption.