Docs
Any backend

elvix is not a Next.js product.

The SDK is React shaped because our first consumers were. The backend contract underneath is two POST endpoints and a bearer token. Kotlin, Go, Python, Ruby, PHP, Rust: if it speaks HTTPS, it speaks elvix.

elvix's SDK is React and Next.js shaped, but nothing about elvix requires either. The backend contract is two POST endpoints that take a bearer token and answer with flat JSON. If your stack can make an HTTPS request, it can integrate with elvix.

The /api/v1/* endpoints return a FLAT { ok, ... } object, NOT the { success, data, code, errorMessage, timestamp } envelope the rest of the API uses. This is deliberate and stable: these are machine-to-machine trust anchors and the envelope buys them nothing. Do not write an unwrapper that expects data.

Verify a user's session token

The browser gets a token when the user signs in. Your backend sends it here to find out who they are, right now, including whether they have since been banned or had their roles changed.

verify a user token
# The user token comes from the browser after sign-in.
curl -X POST https://elvix.is/api/v1/session \
-H "Authorization: Bearer $ELVIX_USER_TOKEN"
# 200. Note the FLAT shape: no { success, data } wrapper.
# { "ok": true, "userId": "...", "email": "[email protected]", "applicationId": "...",
# "roles": ["organizer"], "scopes": [], "memberships": [] }
# 401. Dead, expired, or revoked token.
# { "ok": false, "error": "invalid_token" }
# 403. The user is banned or paused in this app.
# { "ok": false, "error": "user_banned" }

Pass your public clientId as x-elvix-client-id when your app serves more than one elvix Application, so the answer is scoped to the right one.

ElvixClient.kt
// Kotlin / Spring. No elvix SDK exists for the JVM; this is the whole
// integration. Two calls: one at sign-in, none afterwards if you mint your
// own token (see "Do not call this on every request" below).
data class ElvixSession(
val ok: Boolean,
val userId: String? = null,
val email: String? = null,
val name: String? = null,
val applicationId: String? = null,
val roles: List<String> = emptyList(),
val scopes: List<String> = emptyList(),
val memberships: List<String> = emptyList(),
val error: String? = null,
)
suspend fun verifyElvixToken(token: String): ElvixSession =
httpClient.post("https://elvix.is/api/v1/session") {
header("Authorization", "Bearer $token")
header("x-elvix-client-id", ELVIX_CLIENT_ID)
timeout { requestTimeoutMillis = 5_000 }
}.body()

Verify a service key

For machine callers, an eak_ API key identifies a service rather than a person. Same shape, different endpoint.

verify a service key
# Machine-to-machine: verify a service API key (eak_) rather than a user.
curl -X POST https://elvix.is/api/v1/verify \
-H "Authorization: Bearer $ELVIX_API_KEY"
# { "ok": true, "apiKeyId": "...", "applicationId": "...", "userId": "...",
# "roles": [...], "scopes": [...], "memberships": [...], "admin": false }

Do not call this on every request

/api/v1/session reads live state, which is what makes a ban take effect immediately. It also means a network round trip. Calling it on every request makes elvix a hard dependency in your hot path and gives every one of your endpoints elvix's latency plus your own.

Pick one of these instead, in rough order of how much traffic you have:

  1. Cache the answer briefly. A 30 to 60 second TTL keyed on the token removes almost all the calls and still bounds how long a banned user keeps working. Simplest thing that works.
  2. Exchange it once at sign-in. Call elvix a single time when the user arrives, then mint your OWN signed token (a short-lived JWT) and verify that locally on every subsequent request. This is the phantom-token pattern: opaque token at the edge, your own token inside. elvix is then on the login path only, never the request path.
  3. Re-verify on privilege changes. If you exchange, subscribe to the user.banned, user.role_added and user.role_removed webhooks so a revocation reaches you as a push rather than being discovered by a poll.

Option 2 no longer needs your own signing key. elvix publishes a JWKS, so it can hand you a JWT that your framework verifies for itself. See the next section.

The short version: point your framework at elvix

elvix publishes standard OIDC discovery. Any resource-server library does the rest on its own: discovery, key fetching, rotation, signature and audience checks. There is no elvix SDK for your language because with this there is nothing left for one to do.

application.yml
# Spring Boot. This is the whole thing: no custom JwtDecoder, no elvix
# dependency, nothing to keep in sync when elvix rotates its signing key.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://elvix.is
audiences: elvix_pub_live_xxxxxxxx # YOUR clientId
# ASP.NET: options.Authority = "https://elvix.is";
# go-oidc: oidc.NewProvider(ctx, "https://elvix.is")
# python-jose: fetch /.well-known/openid-configuration, read jwks_uri

That is the entire integration. Your handlers receive a verified principal with roles, scopes and memberships claims, and elvix is never called on the request path.

  • GET /.well-known/openid-configuration gives the issuer and the key set URI.
  • GET /.well-known/jwks.json gives the public keys. Both are public, cacheable, and need no credentials.
  • POST /api/v1/token exchanges the opaque session token your frontend holds for a signed JWT to send to your backend.
exchange for a JWT
# Your frontend holds the opaque elvix token. Exchange it once for a JWT
# and send THAT to your own backend.
curl -X POST https://elvix.is/api/v1/token \
-H "Authorization: Bearer $ELVIX_USER_TOKEN"
# { "ok": true, "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6...",
# "token_type": "Bearer", "expires_in": 600,
# "issuer": "https://elvix.is", "audience": "elvix_pub_live_xxxxxxxx" }
# Claims: iss, sub (elvix userId), aud (your clientId), exp, iat,
# roles[], scopes[], memberships[]
#
# Authorization only. No email or name: a bearer token travels through
# proxies and logs. Use sub to look up the profile you already hold, or
# call /api/v1/session when you genuinely need it.

Set the audience to YOUR clientId and let the library enforce it. One issuer serves every elvix tenant, so the aud claim is the whole tenant boundary: a library configured with no audience check would accept a token minted for someone else's application.

Rotation is handled for you. Each key carries a kid, elvix publishes the previous key alongside the new one during a rotation, and your library refetches the key set whenever it meets a kid it does not know.

The trade: a JWT cannot be un-issued, so a ban lands for a local verifier only when the current token expires. Tokens are deliberately short lived for that reason, and ElvixLifecycleWatcher still evicts the browser the moment a ban happens. If you need revocation to be immediate on the backend too, either keep calling /api/v1/session or subscribe to the user.banned webhook.

Error codes

  • invalid_token (401): unknown, expired, revoked, or ended session. Treat as signed out.
  • user_banned / user_paused (403): the identity is real but blocked in this app. Do not treat as signed out; tell the user their access is suspended.
  • deleted (401): the user is gone. Treat as signed out.
  • rate_limited (429): you are sending too many FAILED verifications from one address. Valid tokens are never rate limited, so seeing this means something is spraying bad tokens. Honour Retry-After.

Failed verifications are rate limited per IP; successful ones are not. A busy backend behind a single egress address will never be throttled by volume alone, no matter how many users it serves.

What you still get from the browser side

A non-React frontend can drive sign-in over the same public REST endpoints the SDK uses: POST /api/auth/identifier/resolve, POST /api/auth/otp/start, POST /api/auth/otp/verify, plus the Google and passkey routes. Same backend, same sign-in equals sign-up behaviour. See the API reference for the full list.