Issue, verify and refresh JWT sessions with a hosted auth API

Mint a session, verify its EdDSA access token offline against the JWKS, and rotate refresh tokens — with runnable curl and Node 22 examples.

Infrai’s auth surface gives you three calls for the whole session lifecycle: POST /v1/auth/session/create mints one, POST /v1/auth/session/refresh rotates it, and GET /v1/auth/token/jwks publishes the public key so your backend can verify the access token locally without a round trip. The access token is a short-lived EdDSA JWT. Nothing about it requires an SDK.

That last part is the bit worth pausing on. Most hosted auth products want a library in your request path; here the verification step is standard JWT validation against a published key set, so whatever your stack already uses for JWTs keeps working.

Two ways to mint a session

POST /v1/auth/session/create accepts two shapes, and picking the right one is the main design decision.

Pass email and password when you want the platform to do the credential check. The password is verified against an argon2id hash, and an unknown email and a wrong password both come back as the same AUTH_INVALID_CREDENTIALS — no account enumeration, which is the behaviour you want but rarely get for free.

Pass user_id instead when your own backend has already authenticated the person — your own SSO, a magic link you sent, an OAuth callback you handled — and you just need a session minted. Set method to whichever of password, magic_link, otp, oauth or passkey actually happened, because that value is what later shows up when you inspect the session.

curl -sS -X POST "https://api.infrai.cc/v1/auth/session/create" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"email": "ada@example.com", "password": "correct-horse-battery-staple", "method": "password"}'

The response carries both tokens plus the session record:

{
  "ok": true,
  "data": {
    "session_id": "au_ses_7Uu2kQxWvR4mBn8dTcYs",
    "user_id": "au_usr_lMmXGySJeGVM1xicqHGJaJbB",
    "vendor": "infrai_native",
    "method": "password",
    "state": "active",
    "started_at": "2026-09-21T02:24:30Z",
    "expires_at": "2026-09-28T02:24:30Z",
    "access_token": "eyJhbGciOiJFZERTQSIsImtpZCI6ImluZnJhaS1hdXRoLWVkMjU1MTktdjEiLCJ0eXAiOiJKV1QifQ...",
    "refresh_token": "au_rft_9wQ1zV6pLkS3dHyBnMfE"
  }
}

Hand the access_token to the client. Keep the refresh_token wherever you keep secrets — it is the long-lived half.

Verify the access token offline

The public key set is a plain GET and needs no auth gymnastics:

curl -sS "https://api.infrai.cc/v1/auth/token/jwks" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}"
{
  "ok": true,
  "data": {
    "keys": [
      {
        "kty": "OKP",
        "crv": "Ed25519",
        "use": "sig",
        "alg": "EdDSA",
        "kid": "infrai-auth-ed25519-v1",
        "x": "KlELlwmJ87lR-5UPJ2JaagXal0Zo87THwPusrfKsKg4"
      }
    ]
  }
}

One Ed25519 key, alg: EdDSA, identified by kid. Cache it — that’s the whole point. Fetching the JWKS on every request turns an offline check into a network hop and hands you an availability dependency you didn’t need.

Here is the middleware, Node 22 ESM, with a cache that respects the kid in the token header so a future key rotation resolves itself:

import { createRemoteJWKSet, jwtVerify } from "jose";

const API = "https://api.infrai.cc";
const KEY = process.env.INFRAI_API_KEY;
if (!KEY) throw new Error("INFRAI_API_KEY is not set");

// createRemoteJWKSet caches the key set in memory and refetches only when a
// token presents an unseen `kid`. Ten minutes is a reasonable floor between
// refetches; the cooldown stops a burst of bad tokens becoming a fetch storm.
const jwks = createRemoteJWKSet(new URL(`${API}/v1/auth/token/jwks`), {
  cacheMaxAge: 24 * 60 * 60 * 1000,
  cooldownDuration: 10 * 60 * 1000,
  headers: { authorization: `Bearer ${KEY}` },
});

export async function requireUser(req, res, next) {
  const header = req.headers.authorization ?? "";
  const token = header.startsWith("Bearer ") ? header.slice(7) : null;
  if (!token) return res.status(401).json({ error: "missing bearer token" });
  try {
    const { payload } = await jwtVerify(token, jwks, {
      issuer: "infrai-auth",
      clockTolerance: 5,
    });
    req.user = { id: payload.sub, sessionId: payload.sid };
    return next();
  } catch (cause) {
    // An expired or badly-signed token is a 401 for the caller, not a 5xx for you.
    return res.status(401).json({ error: "invalid session", reason: String(cause?.code ?? cause) });
  }
}

Roughly 40 lines, no vendor library in the hot path, and a verify that costs microseconds instead of a round trip.

Refresh rotates both halves

curl -sS -X POST "https://api.infrai.cc/v1/auth/session/refresh" \
  -H "Authorization: Bearer ${INFRAI_API_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"refresh_token": "au_rft_9wQ1zV6pLkS3dHyBnMfE"}'

You get a new access_token and a new refresh_token — rotation, not reuse — plus expires_in. Store the new refresh token before you acknowledge the request, because the old one is spent. If you hammer refresh in a tight loop, expect AUTH_REFRESH_TOO_FREQUENT; treat it as a signal that your client is refreshing on a timer rather than on expiry.

When to verify online instead

Offline verification proves the token was signed and hasn’t expired. It cannot know that you revoked the session thirty seconds ago. For that, GET /v1/auth/session/verify/{session_id} reads the live record and returns session_id, user_id, started_at, expires_at, and the ip, ua and mfa_factor captured at sign-in.

import os
import requests

API = "https://api.infrai.cc"
KEY = os.environ["INFRAI_API_KEY"]

def session_state(session_id: str) -> dict:
    resp = requests.get(
        f"{API}/v1/auth/session/verify/{session_id}",
        headers={"Authorization": f"Bearer {KEY}"},
        timeout=10,
    )
    if resp.status_code == 404:
        return {"active": False, "reason": "unknown session"}
    resp.raise_for_status()
    data = resp.json()["data"]
    return {"active": data.get("state") == "active", "user_id": data["user_id"], "expires_at": data["expires_at"]}

if __name__ == "__main__":
    print(session_state(os.environ.get("SESSION_ID", "au_ses_example")))
ConcernOffline (JWKS)Online (session/verify)
Latencynone after first fetchone request per check
Sees a revocationonly when the access token expiresimmediately
Needs networknoyes
Good forevery API requestlogout-sensitive and admin actions

The pattern most teams land on: offline on every request, online on the handful of operations where a stale session is actually dangerous. Keep access tokens short and the gap stays small.

Limitations, and where a specialist wins

The honest trade-off is that this is an API, not a product with a front end. There’s no drop-in <SignIn /> component, no hosted account-management page, no prebuilt organisation-invite UI. If you want those on day one, Clerk is the better pick and it isn’t close — you’d be better off buying the UI than rebuilding it. SuperTokens is the one to compare against if self-hosting the whole identity store matters to you.

What you get in exchange is that the next thing this flow needs is already on the same key. The OTP email in a passwordless variant goes out through POST /v1/auth/email/send_code; a phone factor uses POST /v1/auth/phone/send_code; when a login fails in a way you want to see, POST /v1/errors/capture takes it — no second vendor, no second invoice, no second key to rotate.

Every route on this page reports billing_class: free in discovery: session create, refresh, verify and the JWKS are not billed per call. Identity is metered by monthly active user instead, the same shape Auth0 and Clerk use, so GET /v1/account/usage is where you read what your account actually accrued this month — verified 2026-09-21. Read your own account rather than trusting a number in a guide; platform rates move down over time and discount campaigns run, so what you see may well be lower than anything published.

References

Browse more auth developer guides