Every identity provider,
one shape.

Clerk, WorkOS, Auth0, Kinde, Supabase or any OIDC-compliant provider your company already runs — each is an adapter over one shared strategy, and all of them resolve to the same typed subject your handlers read. Social sign-in, SAML SSO and SCIM provisioning stand alongside. Swapping the provider is a configuration change, not a rewrite: no guard, no query and no screen mentions who verified the token.

auth.ts
TypeScript
// app.config.ts — API keys are first-class
export default { apiKeys: true /* … */ }

// auth wiring — passwords, sessions, passkeys, MFA
import { authRoutesPlugin } from '@voltro/plugin-auth'
import { permission } from '@voltro/plugin-rbac'

plugins: [authRoutesPlugin()]   // mounts /auth/*

// guard a mutation — the Effect-native RBAC scope check
import { EffectStore } from '@voltro/runtime'
import { Effect } from 'effect'

export default (input, ctx) => Effect.gen(function* () {
  yield* permission(ctx, 'posts:write')   // fails typed Forbidden
  const store = yield* EffectStore
  return yield* store.insert('posts', input)
})

Every provider, through one seam.

Adapters for the providers people actually use

Clerk, WorkOS, Auth0, Kinde, Supabase and generic OIDC each ship as a plugin over one shared JWT-bearer strategy: tokens verified against the provider's JWKS, claims mapped onto a subject and a tenant. The adapter is the only part of your app that knows which provider it is.

Sign in with Google, without adopting an identity product

Social login is its own plugin and deliberately not a token verifier: Google, GitHub and Apple sign a user in and the framework issues its OWN session — the same one password sign-in issues, with the same device list, revocation and rotation. No vendor sits in the middle to price it.

Enterprise SSO and directory provisioning

SAML 2.0 and SCIM 2.0 are plugins, so the requirements that arrive with a first large customer are a switch rather than a rebuild. Users and groups provisioned by the directory land as memberships the rest of the framework already understands.

One typed subject, whoever issued the token

Handlers read a resolved subject with its scopes and tenant, never a provider SDK. That is what makes the choice reversible — and what lets two front doors run side by side while you migrate, because both resolve to the same thing.

Roles compile to scopes; API keys are first-class

RBAC turns roles into scopes over the protocol's scope system, so the guard on a procedure is the same expression whether the caller signed in through Clerk, through SAML, or with an API key. Keys are issued, listed and revoked through admin-gated routes and stored as hashes.

A complete suite when you would rather not add a vendor

Passwords with modern hashing, signed sessions with key rotation, passkeys, TOTP with recovery codes, magic links, invitations, per-account lockout and multi-tenant memberships — first-party, no third service. Start here and move to a provider later; the guards do not change.

A server-rendered page never arrives signed out

A token older than the provider's lifetime — practically every first page view of the day — used to render an SSR page as an anonymous visitor, because the loader's data is bound from one cookie before any loader runs. `middleware.ts` renews the credential before that binding and writes the rotated cookie back, so the render, its queries and the browser all see the same fresh session. It says where it runs in terms of your routes, so a marketing page never pays for an identity round trip.

Two front doors, one identity model.

Strategies compose, so a migration is not a cutover

An IdP and password sign-in can run at the same time, each resolving to the same typed subject. That is the difference between migrating providers over a quarter and migrating them on a Saturday night: existing users keep their door while new ones use the new one, and no handler can tell which is which. A shared JWT-bearer strategy verifies JWKS and maps claims to a tenant; the React glue (SubjectProvider, useSubject, RequireAuth) is the same either way.

How many front doors are already wired.

6

IdP adapters over one shared JWT-bearer strategy — social login, SAML SSO and SCIM are their own plugins beside them

45

plugins in total, so identity sits beside billing, storage and the rest rather than in a category of its own

The same app code, three different providers.

app.config.ts
ts
// app.config.ts
import { clerkAuth } from '@voltro/plugin-auth-clerk'

plugins: [clerkAuth({ issuer: process.env.CLERK_ISSUER })]

// ─── your application code ───────────────────────────────
guards: [requireScope('invoices:void')]
const subject = ctx.subject          // id, scopes, tenantId

Look at what does NOT change between the tabs: the guard, the subject, the tenant. Only the plugin line moves. That is the whole claim on this page, and it is also why starting with the built-in suite is not a decision you have to get right on day one.

Authentication, in depth.

Which identity provider can I use?

The named ones are Clerk, WorkOS, Auth0, Kinde and Supabase, each shipped as its own plugin. They are not separate integrations: all of them sit over one shared JWT-bearer strategy that verifies a token against the provider's JWKS and maps its claims onto a subject and a tenant.

Anything else goes through the generic OIDC adapter, which is the same strategy pointed at a different issuer — Okta, Entra, Keycloak, or an IdP a platform team runs internally. Social sign-in is a separate plugin on purpose, because Google and GitHub do not need verifying so much as a login flow, and it issues the framework's own session rather than trusting a vendor's token. Enterprise SAML SSO and SCIM provisioning are separate plugins too, because the customers who need them ask for them by name.

And if the answer is none of them, the framework's own suite is a complete one rather than a placeholder: passwords, sessions, passkeys, MFA, invitations and memberships. It exists so that not having chosen a provider is a valid state to ship in, not so you have to stay there.

What actually changes when we swap providers?

The plugin line in your app config, and the credentials. Nothing else in the application refers to the provider: a handler reads a resolved subject, a guard names a scope, a query is bounded by a tenant. None of those is spelled differently because a different service verified the token.

That is not an accident of design — it is the reason to route identity through an adapter at all. A provider SDK reached directly from application code turns a migration into a search-and-replace across every file that ever asked who the caller is, which is how a six-week swap becomes a six-month one.

Because strategies compose, the swap does not even have to be atomic. Two front doors can be open at once with existing users on the old one and new sign-ups on the new one, and no handler can distinguish them.

What stops someone from guessing passwords?

A per-account attempt limiter. After a configurable number of failed credential attempts within a window the account locks for a cooldown, then unlocks itself; a correct login resets the counter. It is on by default, because a security default that has to be enabled is a security default most projects will not have.

The design detail that matters is what it does NOT leak. The counter is keyed by the submitted email rather than by a user row, and a locked address returns the same refusal whether or not an account exists — otherwise the lockout itself becomes an account-existence oracle, which is a worse leak than the one it was added to close.

The sign-in path also runs the password verification even for an address with no account, so the response time does not reveal whether the address is registered. Rate limiting on top of that is a plugin, keyed by IP or subject, for the traffic-shaped half of the problem.

How does auth connect to authorization?

Authentication produces a subject: an identity, a tenant, and a set of scopes. Authorization is what every procedure declares about which scopes may call it — and the boot refuses to start if a wire-reachable procedure or event has neither a guard nor an explicit, written open-access reason.

Roles are a separate concern, supplied by the RBAC plugin, which compiles roles into the same scopes. That indirection is deliberate: a scope is a capability the code checks, a role is an organisational label people argue about, and keeping them apart means renaming a role never silently changes what is enforced.

API keys are first-class rather than a plugin: enable them and you get bearer-key authentication with admin-gated issue, list and revoke, stored as hashes. Keys carry their own scopes, so a key can be strictly weaker than the human who minted it.

What ships in the auth suite.

Authentication capabilities included in the framework
CapabilityWhat you get
PasswordsModern hashing with rehash-on-verify, so parameters can be raised without a reset campaign.
SessionsServer-side records, multi-key rotation, sliding expiry, enumeration and per-session revocation.
MFATOTP with sign-in enforcement and recovery codes; passkeys with atomic clone detection.
Account recoveryMagic links, password reset and email verification with an off / soft / strict policy.
Brute-force defencePer-account lockout with a cooldown, keyed so it cannot reveal whether an account exists.
External identityAdapters for the common providers plus generic OIDC, all over one strategy; SAML SSO and SCIM as their own plugins.

Frequently asked questions

Can I migrate from an existing auth provider?

Yes. Because handlers only ever see a resolved subject, the mechanism that produces it can change without touching authorization code. Running an IdP adapter and first-party passwords side by side during a migration is a supported configuration rather than a workaround.

We are on Auth0 today and may move to WorkOS. What does that cost?

A plugin swap and the new credentials. Both are adapters over the same strategy, so the subject, the scopes and the tenant your code reads are unchanged — and because strategies compose, you can run both while you migrate rather than cutting over in one night.

Does the account lockout leak whether an email is registered?

No, and avoiding that shaped the design. The counter is keyed by the submitted address rather than by a user row, and a locked address returns the same refusal whether or not an account exists. The sign-in path also performs password verification for unknown addresses so timing does not answer the question either.

How do I support multiple organisations per user?

Memberships and a switch-tenant flow are part of the suite. A user can belong to several tenants, and switching produces a new subject scoped to the chosen tenant — the same subject shape every other part of the framework already reads.

Where is session data stored?

In your database, in framework-owned tables that migrate with everything else. There is no external session service and nothing is proxied to third-party infrastructure; the connection string is yours.

Auth composes with the rest of the runtime.

Open the framework. See it for yourself.

Every primitive on this page is in the framework today. Clone the starter, run `voltro dev`, and have it on screen in two minutes.