Shyft SolutionsDocsBuild an app
Shyft Solutions · Developer docs

Build an app on the Shyft portal.

Every Shyft internal tool plugs into the portal the same way: it gets reached through a reverse-proxy that mints a short-lived JWT, the app verifies it with shyft-auth, and reads the user's identity, groups, and role from the claims. This guide walks you through the contract end-to-end and gives you concrete Python and Node snippets you can copy.

Library
shyft-auth
Python · Node · more on demand
JWT lifetime
60 seconds
minted per request by the portal
Signing
RS256 + JWKS
rotation transparent to apps
Status
Pre-alpha
libraries publish 2026-Q3

This is the developer-facing companion to the auth design docs. Read it once before you start wiring up a new app, and again when you're ready to register your app with the portal admin UI. Each language-specific snippet sits behind a tab — the concepts above the snippet are agnostic, the code below them is whatever you're shipping.

Working assumption:you're building a new internal app (FastAPI, Flask, Express, or Next.js) that needs to read "who is this user, and what are they allowed to do here?" from every incoming request. You do not need to implement sign-in, sign-out, sessions, or password reset — the portal handles all of that. You just need to verify the JWT the portal forwards and act on the claims.

How it fits together

The portal is a Backend-for-Frontend (BFF). It owns the browser session, talks to Authentik on your behalf, and forwards each request to your app over a private network with a freshly-minted JWT. Your app only ever sees those JWTs.
Request path
Browser
portal_session cookie
Portal (Next.js)
this repo
Your app
FastAPI · Flask · Next.js · …
Authentik
federates to Google
Portal JWKS
/.well-known/jwks.json

Three rules that shake out of the topology and constrain everything you build:

Rule 1
Your app never sees portal cookies

Apps only see the JWT in Authorization: Bearer …. You do not parse, forward, or persist portal_session— you wouldn't have it in topology A anyway.

Rule 2
Every request is independently authorized

JWTs live 60 seconds. There is no in-app session cache. Re-verify on every request; there's no "login" in your app to keep alive.

Rule 3
Role names belong to the app

You declare your roles in code and expose them at /.well-known/app-roles. The portal admin maps Authentik groups → your role names in the registration UI.

Tokens & sessions

Three tokens exist in this system. Your app sees exactly one of them.
TokenIssued byAudienceLifetimeApps see it?
Authentik ID tokenAuthentikPortal (OIDC client)~1 hourNo
Portal session cookiePortalBrowser only~12 hoursNo
Portal-minted app JWTPortal (RS256)aud=<your-slug>60 secondsYes — every request
Sign-in (happens once, ~12h)
Browser
Portal
Next.js
Authentik
Google
GET /
302 → /authorize (PKCE)
302 → Google OIDC
Render Google login (+ MFA)
callback(code_g)
302 → /api/auth/callback(code_a)
POST /token (code_a)
id_token + refresh + groups
INSERT into user_sessions · snapshot groups · set HttpOnly cookie
302 → / + Set-Cookie portal_session
App launch (happens on every request to your app)
Browser
Portal
BFF proxy
Your app
shyft-auth
GET /apps/training/dashboard + cookie
resolve session · pick app_role from groups
mint JWT (RS256, exp = now + 60s)
GET /dashboard + Authorization: Bearer <jwt>
(cache miss) GET /.well-known/jwks.json
JWKS · cached 1h
verify sig · iss · aud · exp · extract app_role
200 OK
200 OK (streamed)

Why 60 seconds?Short enough that we don't need a revocation list. If the portal session is killed (sign-out, admin disable, group removed) the next request stops at the proxy before a fresh JWT is minted, and any JWT the user holds in flight expires within a minute. See the FAQ for details.

Claim contract

Every JWT the portal mints — and every JWT Authentik would mint, if we ever flip to topology A — has the same shape. Your app should never branch on iss; rely on aud, sub, groups, and app_role.
{
  // standard claims
  "iss": "https://portal.shyftsolutions.io",
  "aud": "training",
  "sub": "alice@shyftsolutions.io",
  "iat": 1715600000,
  "exp": 1715600060,
  "jti": "01HX...",

  // identity
  "name": "Alice Example",
  "email": "alice@shyftsolutions.io",
  "groups": ["employees", "training-users"],

  // authorization
  "app_role": "user"
}
ClaimRequiredLibrary checksWhat you read it for
issYesValidated against your allowlistInformational — don't branch on it
audYesMust equal your app slugDefense in depth — library rejects mismatches
subYesStable user identifier (email today)
exp, iatYes±5s clock skewLibrary rejects expired tokens automatically
name, emailYesDisplay, audit logs, ownership
groupsYesRaw Authentik group names, filtered to those this app cares about
app_roleYesResolved by the portal from your group→role mapping. This is what you gate features on.
Stability promise

Adding new claims is non-breaking. Renaming or removing a claim is a major version bump of shyft-authcoordinated with the portal's minter — your pinned library version protects you from surprises.

Shape your app

The portal doesn't care if your app is one process or three. But the typical Shyft internal app is Next.js front-end + FastAPI back-end + Postgres, and that shape has a recommended layout. Read this before you register your app so the slug, audience, and role mapping all line up.
Recommended: one registration, internal API
Browser
portal_session cookie
Portal
mints JWT (aud=my-app)
Next.js
frontend · the only public face
FastAPI
internal podman net only
Postgres

One slug, one role mapping, one audience.The FastAPI runs on the internal podman network and never publishes a port outside the host. The Next.js forwards the user's portal JWT to FastAPI on every request, and FastAPI verifies the same JWT with the same aud=my-app using shyft-auth.

Why
Admin UX

Two slugs means two group→role mappings to keep in sync. That's a maintenance burden and a likely source of "I gave Alice admin but the API still 403s" bugs.

Why
Audience semantics

aud answers "which app is this token for?" The user sees one app. Splitting aud across deployment layers leaks topology into the security model.

Why
The API isn't user-facing

It's only called by your own Next.js. There's no second user flow to gate; the role mapping is already settled by the time Next.js reaches the API.

Forwarding the JWT

For server-side fetches (Server Components, Route Handlers), pull Authorization off the incoming request and forward verbatim. The portal already put the JWT there.

app/dashboard/page.tsx
import { headers } from "next/headers";

export default async function Dashboard() {
  const auth = (await headers()).get("authorization");
  const res = await fetch(`${process.env.MY_APP_API_URL}/things`, {
    headers: { Authorization: auth ?? "" },
    cache: "no-store",
  });
  const things = await res.json();
  return <ThingsView things={things} />;
}

For browser-initiated fetches, route them through your Next.js /api/*routes (which forward the JWT as above). Don't expose the FastAPI publicly to call it directly from the browser — that defeats the internal-only property that makes "trust the JWT" safe.

FastAPI verification

from fastapi import Depends, HTTPException, Request
from shyft_auth import (
    AppClaims,
    MissingTokenError,
    PortalAuthError,
    verify_portal_jwt,
)

def require_user(request: Request) -> AppClaims:
    try:
        return verify_portal_jwt(
            request,
            expected_aud="my-app",
            expected_iss="https://portal.shyftsolutions.io",
            jwks_url="https://portal.shyftsolutions.io/.well-known/jwks.json",
        )
    except MissingTokenError:
        raise HTTPException(401, "Not authenticated")
    except PortalAuthError:
        raise HTTPException(401, "Invalid credentials")

# usage:
# @app.get("/things")
# def list_things(user: AppClaims = Depends(require_user)):
#     if user.app_role not in {"admin", "editor", "viewer"}:
#         raise HTTPException(403)
#     ...
When two registrations make sense

Rare. (1) The API has a second consumer — another app, a CLI, a webhook. That's server-to-server auth, deferred to a future phase. (2) The API and the front-end need different access lists. You can already express this with app_role checks inside the API — cheaper than splitting the registration.

Deployment shape: both halves run as podman quadlets on the same internal network. Only the Next.js publishes a port to the host. Both expose /health. The FastAPI's Postgres is its own database — don't share with the portal's session store.

Quickstart

Five steps from a fresh repo to a JWT-verified route. The deep dives below each step are linked in the sidebar — but if you skim this section top-to-bottom you have everything you need.

1. Install the library

shyft-authis published to GitHub Packages — npm for Node, PyPI for Python. You'll need a personal access token with read:packages scope. See Install & configure for the auth-token dance.

uv | pip
# uv (recommended)
uv add shyft-auth --index https://__token__:$GH_TOKEN@pypi.pkg.github.com/shyftsolutions/

# or pip
pip install shyft-auth --index-url https://__token__:$GH_TOKEN@pypi.pkg.github.com/shyftsolutions/

2. Tell the library who you are

Set three environment variables. The portal will give you the slug when you register your app — until then, pick something descriptive (it's the identifier you'll request from the admin).

# The aud claim the portal will mint for your app
SHYFT_APP_AUD=training

# The portal's JWKS endpoint (no trailing slash)
SHYFT_PORTAL_ISSUER=https://portal.shyftsolutions.io
SHYFT_PORTAL_JWKS_URL=https://portal.shyftsolutions.io/.well-known/jwks.json

3. Verify a request

One function call, one line in your handler. The library handles signature verification, issuer + audience checks, expiry, clock skew, and JWKS caching.

dependency
# main.py
from fastapi import FastAPI, Depends
from shyft_auth.fastapi import verify_portal_jwt
from shyft_auth import AppClaims

app = FastAPI()

@app.get("/whoami")
def whoami(claims: AppClaims = Depends(verify_portal_jwt(expected_aud="training"))):
    return {
        "email": claims.email,
        "groups": claims.groups,
        "role": claims.app_role,
    }

4. Declare your roles

The portal admin needs to know which role names your app understands so they can map Authentik groups to them. Expose them at /.well-known/app-roles:

router
from shyft_auth.fastapi import app_roles_router

app.include_router(app_roles_router([
    {"name": "user", "description": "Submit training records"},
    {"name": "admin", "description": "Manage all training data"},
]))
# now GET /.well-known/app-roles → { "roles": [...] }

5. Register your app with the portal

Ask a portal admin (or use the /admin/appsUI if you have access) to register your app's slug, base URL, and group→role mappings. The admin clicks "Discover roles" — your /.well-known/app-roles response is what pre-fills the form.

Coming soon

The /admin/apps UI ships in Phase H. Until it lands, registration is a DM to whoever has portal DB access. See Portal admin walkthrough for the planned screens.

Install & configure

GitHub Packages requires a personal access token to install — even for public packages in the org. This is a one-time setup per dev machine and per CI runner.

1. Generate a token

Visit github.com/settings/tokens/new (classic) and create a token with only the read:packagesscope. Set the expiry to whatever your team's policy allows. Save the token in your password manager — it's shown once.

2. Configure your package manager

Replace ghp_yourTokenHere with the token you just generated. NEVER commit this file.
~/.config/pip/pip.conf
[global]
extra-index-url = https://__token__:ghp_yourTokenHere@pypi.pkg.github.com/shyftsolutions/
Don't commit the token

In CI, set NPM_TOKEN / GH_TOKEN as a repo secret and interpolate it into a project-local .npmrc or pip.conf as part of the workflow — never commit one with a real token in it.

3. Verify the install worked

python -c "from shyft_auth import __version__; print(__version__)"
# 0.1.0

Verify a request

Anatomy of what verifyPortalJwt / verify_portal_jwt actually does for you, what it can throw, and how to respond.

Both libraries do the same six things, in this order:

  1. Read the bearer token from the Authorization header.
  2. Decode the JWT header to extract kid.
  3. Fetch the JWKS (cached) from the portal, look up the public key.
  4. Verify the RS256 signature.
  5. Check iss against your allowlist, aud against your slug,exp + iat with ±5s skew.
  6. Return a strongly-typed AppClaims object — or raise a typed error you can pattern-match on.

If any check fails, the call raises a specific error subclass. Map them to HTTP statuses in one place — middleware, error handler, or decorator wrapper — so your route handlers stay clean.

Error taxonomy

ErrorMeansRecommended response
MissingTokenErrorNo Authorization header on the request.401· "authentication required"
InvalidSignatureErrorJWKS lookup OK but signature does not verify.401 · log it, this should not happen with a healthy portal
ExpiredTokenErrorexp in the past beyond skew.401 · client should retry through portal
AudienceMismatchErroraud in the JWT does not equal your expected_aud.401 · usually means your app was deployed with the wrong slug
IssuerMismatchErroriss not in your allowlist.401 · likely a misconfigured env var

Reusable error-to-HTTP mapping

exception handler
from fastapi import Request
from fastapi.responses import JSONResponse
from shyft_auth import PortalAuthError, MissingTokenError

@app.exception_handler(PortalAuthError)
def auth_error_handler(request: Request, exc: PortalAuthError):
    status = 401
    if isinstance(exc, MissingTokenError):
        return JSONResponse({"error": "authentication_required"}, status_code=status)
    return JSONResponse({"error": exc.__class__.__name__}, status_code=status)
Authorization vs. authentication

verify_portal_jwt tells you who the user is and what role they have. It does nottell you whether they're allowed to do a specific action — that's your app's business logic. Use has_role(claims, "admin") / hasRole(claims, "admin") at the action boundary, not at the middleware boundary.

Roles & discovery

Roles are your app's vocabulary. The portal doesn't care what they mean; it just maps Authentik groups to them and forwards the resolved value in the JWT's app_role claim. Two things matter: declaring them clearly, and gating features on them precisely.

1. Define your roles in code

Keep your role list in a single file. Reference it everywhere — the well-known endpoint, your authorization checks, your tests. Then a future role rename is one commit instead of grep-and-pray.

app/roles.py
# app/roles.py
APP_ROLES = [
    {"name": "user",     "description": "Submit training records"},
    {"name": "approver", "description": "Approve or reject submissions"},
    {"name": "admin",    "description": "Manage everyone's training data"},
]

ROLE_NAMES = {r["name"] for r in APP_ROLES}

2. Expose /.well-known/app-roles

The portal's admin UI calls this endpoint during registration (and any time an admin clicks "Re-discover") to pre-fill the role dropdown. If the endpoint is unreachable, the admin can type role names manually — but discovery is the happy path, so wire it up.

router
# app/main.py
from shyft_auth.fastapi import app_roles_router
from .roles import APP_ROLES

app.include_router(app_roles_router(APP_ROLES))
GET /.well-known/app-roles HTTP/1.1
Host: training.shyftsolutions.internal

HTTP/1.1 200 OK
Content-Type: application/json

{
  "roles": [
    { "name": "user",     "description": "Submit training records" },
    { "name": "approver", "description": "Approve or reject submissions" },
    { "name": "admin",    "description": "Manage everyone's training data" }
  ]
}

3. Gate features on the role

app_role is a single string. Pick one of three idioms; they all map to the same library helpers.

dependency
from fastapi import HTTPException, Depends
from shyft_auth import has_role, AppClaims
from shyft_auth.fastapi import verify_portal_jwt

def require_role(role: str):
    def dep(claims: AppClaims = Depends(verify_portal_jwt(expected_aud="training"))):
        if not has_role(claims, role):
            raise HTTPException(status_code=403, detail=f"requires {role}")
        return claims
    return dep

@app.delete("/records/{id}")
def delete_record(id: str, claims = Depends(require_role("admin"))):
    ...

How groups become roles

You declare role names; an admin maps Authentik groups to them in the portal. The mapping has an explicit priority — lowest number wins when a user is in multiple matching groups.
Mapping resolution at sign-in
User's groups
alice ∈ training-admins, training-users
Mappings (sorted)
1 · training-admins → admin 2 · training-users → user
app_role
admin
Why groups use UUIDs, not names

The portal stores each mapping's Authentik group reference as a UUID (the pk field), not the name. A rename in Authentik is invisible to your app — the mapping still resolves, and the admin UI always shows the current name.

Local development

You don't want to sign in through Google + Authentik every time you restart your dev server. @shyft/auth-devserver is a tiny standalone process that mints JWTs against a localhost JWKS endpoint — point your app at it instead of the portal.
Dev-time topology
curl / browser
Your app
SHYFT_PORTAL_JWKS_URL=localhost:9999
auth-devserver
@shyft/auth-devserver

1. Run the devserver

# anywhere — no install needed
npx @shyft/auth-devserver

# devserver listens on :9999 by default
#   GET /.well-known/jwks.json   - the public key
#   GET /login                   - mint a token via web form
#   POST /mint                   - mint a token via JSON

2. Point your app at it

SHYFT_APP_AUD=training
SHYFT_PORTAL_ISSUER=http://localhost:9999
SHYFT_PORTAL_JWKS_URL=http://localhost:9999/.well-known/jwks.json

3. Mint a token and hit your endpoint

# 1. Open http://localhost:9999/login in a browser
# 2. Fill: aud=training, app_role=admin, groups=training-admins,employees
# 3. Submit — page shows the JWT, sets a dev_jwt cookie
# 4. Click "Copy curl" and run it
Test key rotation locally

Pass --rotate-keyto the devserver to force a new keypair. Your app's cached JWKS will miss on the next request and refetch — confirming your production rotation will be transparent.

Never use the devserver in production

The devserver's private key lives on disk in plaintext at ~/.cache/shyft-auth-devserver/keys.json. The production shyft-auth library has no devserver codepath at all. The dev-vs-prod distinction is which JWKS URL you point at, nothing more.

Coming soon

@shyft/auth-devserver publishes to GitHub Packages in Phase D of the initial-auth plan. Until then, clone the shyft-auth-devserver repo and pnpm dev.

Portal admin walkthrough

Once your app verifies a JWT locally, the last step is telling the portal it exists. An admin does this through /admin/apps — the screens below are the planned UX. Until the Phase H build lands, the same data can be inserted directly into the portal Postgres.
Coming soon — Phase H

/admin/apps, /admin/groups, and /admin/audit are wireframed and locked in .claude/artifacts/initial-design/docs/mockup-spec.md. They'll ship after the portal's JWT minter + reverse-proxy are working end-to-end. The flow described below is what you'll see on day one.

portal.shyftsolutions.io / admin / apps / new
Register a new app
Step 1 of 3 · Identity
Slug
training — appears in /apps/<slug>/ and as aud in the JWT
Display name
Training Tracker
Base URL
http://training:8000 (internal podman name)
Health path
/health
Category
Compliance
Status
live · beta · alpha · planned
portal.shyftsolutions.io / admin / apps / new — step 2
Discover roles
Step 2 of 3 · Pulled from /.well-known/app-roles
✓ Found 3 roles at http://training:8000/.well-known/app-roles
  • user · Submit training records
  • approver · Approve or reject submissions
  • admin· Manage everyone's training data
If discovery fails (unreachable, malformed JSON), an inline error reveals a manual textarea — and a Re-discover button on the edit page lets you retry once the app is up.
portal.shyftsolutions.io / admin / apps / new — step 3
Map groups → roles
Step 3 of 3 · Drag rows to reorder priority — lowest number wins
⋮⋮ 1training-adminsadminuuid linked
⋮⋮ 2training-approversapproveruuid linked
⋮⋮ 3training-usersuseruuid linked
⋮⋮ 4employeesuseruuid linked
The mapping stores Authentik group UUIDs, not names. Group renames in Authentik are transparent.

Groups & mappings

Authentik groups are the source of truth for who can do what. The portal admin UI is a thin shell over Authentik's REST API — every create / edit / delete is logged in the portal's audit table with the admin's email, even though Authentik sees only the portal's service-account token.

Group naming is freeform. The admin UI suggests <app-slug>-<role> as a placeholder (e.g. training-admins, training-approvers), but cross-app groups like internal-admin or finance-leads are fully supported — map them into multiple apps as needed.

Nested groupswork if Authentik resolves parents at issuance time. If it doesn't, the portal resolves them at sign-in and snapshots the flattened set into the session record. Either way, your app sees the resolved list in claims.groups.

Suggested layout

# Cross-app
- internal-admin            # super-admin for everything
- employees                 # everyone with a badge

# Per-app, scoped
- training-admins           # full admin in training tracker
- training-approvers        # mid-tier
- training-users            # read/submit
- stipend-admins
- stipend-approvers
- stipend-users
Cross-app shortcut

Want every internal admin to be admin in every app? Map internal-admin → adminat priority 0 in each app's mappings. Lower number wins, so it'll always beat per-app mappings.

Health & liveliness

The portal polls every registered app's health endpoint every 30 seconds. The result colors the tile and signals to admins whether your app is reachable, degraded, or simply not deployed yet.

The contract

GET /health HTTP/1.1
Host: training.shyftsolutions.internal

HTTP/1.1 200 OK
Content-Type: application/json

{
  "ok": true,
  "version": "1.4.0",
  "uptime_s": 18241
}
StateTriggerTile appearance
liveLast poll 2xx within 60sGreen dot · clickable
degradedLast poll 3xx / 4xx (besides 404)Orange dot · clickable (warning banner inside app)
unreachable5xx or timeout for > 2 consecutive pollsRed dot · clickable (502 page if launched)
not-deployedRegistered < 5 min ago, never returned 2xxGrey dot · "Coming soon"
disabledAdmin flipped enabled = falseHidden from non-admins entirely
Tip

Keep /health cheap and unauthenticated. The portal hits it 2,880 times per app per day. If your app needs to verify dependencies (DB, queue), do it on a separate /health/deepthat's polled less frequently.

Testing

Two layers of testing. Unit tests use the library's buildClaims() / build_claims() helper to construct fake claims without touching crypto — fast, no JWKS. Integration tests boot the dev-server and mint real signed JWTs — slow, but proves the wire contract.

Unit tests with fake claims

FastAPI dependency_overrides
# tests/test_handlers.py
from fastapi.testclient import TestClient
from shyft_auth.testing import build_claims
from shyft_auth.fastapi import verify_portal_jwt
from app.main import app

client = TestClient(app)

def test_admin_can_delete():
    fake_admin = build_claims(app_role="admin", email="alice@shyftsolutions.io")
    app.dependency_overrides[verify_portal_jwt(expected_aud="training")] = lambda: fake_admin

    res = client.delete("/records/abc")
    assert res.status_code == 204

def test_user_cannot_delete():
    fake_user = build_claims(app_role="user")
    app.dependency_overrides[verify_portal_jwt(expected_aud="training")] = lambda: fake_user

    res = client.delete("/records/abc")
    assert res.status_code == 403

Integration test with the devserver

For the wire contract — "does my app actually verify a real signed JWT against a real JWKS" — boot @shyft/auth-devserveras a fixture, mint a token, and hit your handler. This is what the library's own CI does for every pull request.

session fixture
# tests/conftest.py
import subprocess, time, requests, pytest

@pytest.fixture(scope="session")
def devserver():
    proc = subprocess.Popen(["npx", "-y", "@shyft/auth-devserver", "--port", "19999"])
    for _ in range(30):
        try:
            requests.get("http://localhost:19999/.well-known/jwks.json", timeout=0.5)
            break
        except Exception:
            time.sleep(0.2)
    yield "http://localhost:19999"
    proc.terminate()

@pytest.fixture
def mint(devserver):
    def _mint(**claims):
        return requests.post(f"{devserver}/mint", json=claims).json()["token"]
    return _mint

Key rotation

The portal signs JWTs with an RS256 keypair. Rotation is quarterly + on-incident and your app does not need to be redeployed for it.

How it works:

  1. Portal generates a new keypair with a fresh kid.
  2. Portal publishes both the old and new public keys in /.well-known/jwks.json.
  3. Portal switches signing to the new key.
  4. Wait ≥ 61 minutes (1h JWKS cache + 60s JWT lifetime).
  5. Portal removes the old public key from JWKS.

shyft-auth reads the JWT's kid header, checks its cached JWKS, and re-fetches on miss. The whole rotation is transparent to your code.

Verify locally

Run npx @shyft/auth-devserver --rotate-keywhile your app is hitting it. Your next request should succeed without restart — that's the rotation path.

FAQ

The questions we already know you'll ask. If something isn't covered, ask in #platformand we'll add it here.

Can I cache the verified claims for the rest of my request?

Yes — verification is the only step that's expensive (the signature check). Once you've called verify_portal_jwt once, stash the result on request.state / req.shyftClaims / context for the duration of the request. Do not cache across requests — every request is its own JWT.

What if my app is down when the portal tries to verify it during registration?

Registration succeeds anyway. The admin can hit "Re-discover" later, or type the role names manually. Tile shows not-deployed until the first successful health probe.

How long do users stay signed in? Do I need to refresh tokens?

User sessions live ~12 hours in the portal's DB. The portal mints a fresh 60s JWT for yourapp on every proxied request — there's nothing for you to refresh.

Group memberships changed in Authentik. When does my app see the new groups?

On the user's next sign-in. The portal snapshots groups into the session record at sign-in and doesn't re-fetch mid-session. Users with stale groups need to sign out and back in.

Does the portal proxy WebSockets?

Not in v1. Plain HTTP only (every verb, but no Upgrade: websocket). If your app needs real-time, flag it in #platformand we'll re-evaluate the deferral.

Server-to-server: can my background worker call another app?

Also deferred in v1. There's no end-user identity for a worker request, so the current JWT shape doesn't fit. When a real use case shows up we'll introduce a service-account JWT shape; until then, workers should talk to each other's data through shared DBs or queues.

What if I need to call another Shyft app from my app?

Same answer — deferred. For v1, do data integration at the DB / queue layer, not through the proxy.

How do I migrate from the portal proxy to a standalone OIDC client (topology A)?

shyft-auth exposes oidcClient() / oidc_client() as a parallel entry point with the same AppClaimsshape. Swap the import, wire up the callback handler, point Authentik at your new subdomain — your business logic doesn't change. See .claude/artifacts/initial-design/docs/internal-auth-contract.md for the full cutover playbook.

Can I read issto decide which mode I'm running in?

No. Mode is chosen by which entry point you import, not by inspecting the token. Hidden mode-switching is the kind of bug that surfaces six months later in production.

Source docs

This guide condenses .claude/features/initial-auth/plan.md, .claude/features/initial-auth/research.md, and the four design docs under .claude/artifacts/initial-design/docs/. When the docs and this page disagree, the design docs win — file an issue and we'll reconcile.