Skip to content

Authentication ​

How a client authenticates against the hub: obtaining a token, presenting it on subsequent requests, and what to do when one is rejected.

Obtaining a token ​

POST /api/v1/login exchanges an email and password for a token.

bash
curl -sS https://hub.sevorix.com/api/v1/login \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@example.com", "password": "correct horse battery staple"}'
json
{
  "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "email": "you@example.com",
  "is_endorsed": false,
  "require_email_update": false
}

token is the only field you need in order to keep calling the API; the rest describe the account that just signed in. require_email_update is true for an account whose address was backfilled by a migration and which must set a real one via PATCH /api/v1/me/email before using the service normally.

There is no separate token endpoint, no refresh token, and no OAuth flow. Log in again to get a new token.

Presenting a token ​

Send it in an Authorization header, with the Bearer prefix:

bash
curl -sS https://hub.sevorix.com/api/v1/me \
  -H 'Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...'

The prefix is matched literally, including its single trailing space. A header that omits it — or uses a different scheme — is treated as if no header were sent at all.

The hub never authenticates by cookie. Nothing you can set in a browser will be picked up as a credential; the header is the only channel.

Token properties ​

PropertyValue
FormatJWT, HMAC-SHA256 (HS256)
Claimssub (user id, UUID as a string), email, exp (Unix seconds)
Lifetime30 days from issue
ValidationSignature and expiry only — no database read

Two consequences follow from that last row, and both are deliberate:

  • Verification is stateless. The hub does not look up a session on each request, so a token is accepted as long as it verifies and has not expired.
  • Changing or resetting a password does not invalidate existing tokens. A token issued before the change keeps working until it expires. If a token may have been exposed, treat a password change as insufficient on its own.

PATCH /api/v1/me/email is the one endpoint that mints a fresh token as part of its response, because the email claim inside the old one is no longer accurate. Replace your stored token with the one it returns.

Endpoints that do not require a token ​

Most read paths work anonymously, and some change what they return if a token is present:

EndpointWithout a tokenWith a token
POST /api/v1/registerThe normal way to call itHeader ignored
POST /api/v1/loginThe normal way to call itHeader ignored
POST /api/v1/password-reset/*The only way to call itHeader ignored
GET /api/v1/users/:user_idFull public profileSame
GET /api/v1/artifacts/searchPublic artifacts onlyPublic artifacts plus your own private and draft ones
GET /api/v1/artifacts/:name/:versionPublic artifacts onlyAlso your own private and draft ones
GET /api/v1/artifacts/:name/:version/resolvePublic artifacts onlyAlso your own private and draft ones
GET /api/v1/artifacts/:artifact_id/endorsementsFull listSame
GET /healthAlways openSame

On the optional-auth endpoints an invalid or expired token is not an error — it is silently treated as anonymous. If a private artifact of yours appears to have vanished, check that your token is still valid before concluding the artifact is gone.

Everything else requires a valid token.

Telling 401 and 403 apart ​

This API does not use the two statuses interchangeably, and the difference tells you whether retrying can ever work.

401 Unauthorized — "I do not know who you are" ​

Your identity was not established, or a credential you supplied did not check out. Returned for:

  • a missing Authorization header;
  • a token that is invalid, expired, or signed with the wrong key;
  • a token whose sub claim is not a well-formed UUID;
  • a token that verifies but names an account that no longer exists;
  • the wrong password on POST /api/v1/login;
  • the wrong current_password on PATCH /api/v1/me/password — even though the request also carried a perfectly valid bearer token. You are authenticated as a session, but you failed to prove the credential this operation asks for, and that is an authentication failure, not a permission one.

The right response is to obtain a valid credential — log in again, or ask the user for the correct password.

403 Forbidden — "I know who you are, and no" ​

Your token verified and your account was found. The action is refused on its merits. Returned for:

  • publishing an artifact from an account that has not been approved yet;
  • endorsing an artifact from an account that has not been approved yet;
  • yanking or unyanking an artifact you do not own.

Retrying with the same account will produce the same result. Nothing about the token needs fixing, so a client should not treat a 403 as a signal to refresh credentials or sign the user out.

One known exception

DELETE /api/v1/artifacts/:artifact_id/endorsements/:endorsement_id returns 401, not 403, when you try to delete an endorsement that is not yours. By the rule above it should be a 403; it is documented here as it actually behaves, and may change in a future release.

Hidden artifacts return 404 ​

There is a third case that is neither of the above. When you request an artifact whose visibility is private or draft and it is not yours, the hub answers 404 Not Found — the same response you would get if it had never been published.

This is intentional. A 403 would confirm that name@version exists, which is information the owner of a private artifact has not agreed to share. The download counter is likewise not incremented for a request that fails this check.

So 404 on an artifact path means "not found or not yours", and a client cannot distinguish the two. That is the point.

Registration and approval ​

POST /api/v1/register creates an account immediately, but not a usable publisher: new accounts start unapproved, and publishing or endorsing returns 403 until that changes. Reading, searching, signing keys, and billing all work in the meantime.

Registration does not return a token. Call POST /api/v1/login afterwards.

Password rules ​

The same rules apply everywhere a password is chosen — registration, change, and reset:

RuleValueWhy
Minimum length8 bytesThe floor, applied at every entry point.
Maximum length1024 bytesA denial-of-service bound, not a style rule: Argon2 hashes whatever it is handed.
CompositionNoneNo "must contain a digit" rules — following NIST SP 800-63B, which favours length over composition rules that push users toward predictable substitutions.

Lengths are measured in bytes, not characters, so a passphrase of multi-byte characters reaches the floor sooner than its character count suggests.

The 1024-byte ceiling also applies to current_password on a change request, even though that value is only verified and never stored — it is the one check that has to run before any hashing happens.

Ordering, on a password change ​

PATCH /api/v1/me/password verifies current_password before it validates new_password, and this ordering is load-bearing. A caller who does not know the current password gets the same 401 whatever they send as the new one, and cannot use "your new password is too short" as a signal that the current one was accepted.

For the same reason, the rule that a new password must differ from the current one is only checked after the current one has been verified — otherwise it would be a free oracle for guessing it.

Password reset ​

For a user who cannot log in at all, and therefore cannot use the change-password endpoint.

Requesting a reset ​

POST /api/v1/password-reset/request takes {"email": "..."} and answers 202 Accepted with the same body for every caller:

json
{ "status": "if that address is registered, a reset link has been sent" }

That response is identical whether the address is registered, is not registered, or belongs to an account that has used up its hourly allowance. The endpoint is built not to leak whether an address has an account — including in its timing: the outbound email send is detached so it cannot lengthen the response for a real account.

Two responses do differ, and neither depends on the address:

  • 400 — the body could not be deserialized.
  • 503 — this instance has no email provider configured, so no link can ever arrive. Reported honestly rather than answering 202 and promising an email that will never be sent.

The token ​

PropertyValue
Lifetime30 minutes
UsesOne — redeeming it consumes it
Per-account limit3 tokens per hour
Live tokens per account1 — issuing a new one invalidates any outstanding token
StorageOnly a SHA-256 hash is stored; the token itself is never logged

The per-account limit is the control that protects a specific person's inbox; the per-IP rate limit only throttles a client. Requesting a reset while the user is midway through an earlier link will invalidate that earlier link.

Confirming a reset ​

POST /api/v1/password-reset/confirm takes {"token": "...", "new_password": "..."} and returns {"status": "password updated"}.

Every token it will not accept — unknown, expired, already redeemed, or redeemed concurrently by a racing request — produces exactly one response:

json
{ "error": "invalid or expired reset token" }

with status 400. The distinctions are collapsed on purpose: each one would be a free oracle for whoever is holding a stale link.

Password strength is checked only after the token is found to be valid, and before it is consumed — so a rejected weak password does not burn the user's one link.

A reset does not sign out existing sessions, for the same reason a password change does not: tokens are stateless and live for 30 days. The reset email says so explicitly.

A worked example ​

Register, sign in, and make an authenticated call:

bash
BASE=https://hub.sevorix.com

# 1. Create the account. Returns 201 and the new user's id.
curl -sS "$BASE/api/v1/register" \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@example.com", "password": "correct horse battery staple"}'

# 2. Exchange credentials for a token.
TOKEN=$(curl -sS "$BASE/api/v1/login" \
  -H 'Content-Type: application/json' \
  -d '{"email": "you@example.com", "password": "correct horse battery staple"}' \
  | jq -r .token)

# 3. Use it.
curl -sS "$BASE/api/v1/me" -H "Authorization: Bearer $TOKEN"

Step 3 returns the caller's own profile:

json
{
  "id": "6f1c0e2a-4b9d-4a1f-8f0c-2f4a9d3e7b11",
  "email": "you@example.com",
  "is_endorsed": false,
  "is_approved": false,
  "created_at": "2026-03-14T09:21:07.481293Z"
}

Note is_approved: false on a freshly registered account — see Registration and approval.

Keep going in the Endpoints reference.

Runtime containment for autonomous AI agents.