atlas-auth / atlas-php
Official PHP backend SDK for Atlas — the secret-key Backend API client plus session-token/JWT verification.
Requires
- php: >=8.1
- ext-json: *
- firebase/php-jwt: ^7.0
- guzzlehttp/guzzle: ^7.5
- psr/http-client: ^1.0
- psr/http-factory: ^1.0
- psr/http-message: ^1.1 || ^2.0
- psr/http-server-handler: ^1.0
- psr/http-server-middleware: ^1.0
Requires (Dev)
- illuminate/http: ^10.0 || ^11.0
- illuminate/support: ^10.0 || ^11.0
- phpunit/phpunit: ^10.5
- symfony/config: ^6.4 || ^7.0
- symfony/dependency-injection: ^6.4 || ^7.0
- symfony/http-kernel: ^6.4 || ^7.0
- symfony/security-http: ^6.4 || ^7.0
- symfony/yaml: ^6.4 || ^7.0
Suggests
- illuminate/http: Required by the Laravel route middleware (Atlas\Laravel\AtlasAuthenticate, RequirePermission).
- illuminate/support: To use the Laravel integration (Atlas\Laravel): the AtlasServiceProvider, config/atlas.php, and the atlas.auth / atlas.permission route middleware.
- psr/http-server-middleware: To use the framework-agnostic PSR-15 middleware (Atlas\Psr15\AtlasAuthenticationMiddleware) in Slim, Mezzio, etc.
- symfony/dependency-injection: Required to wire the Symfony bundle's services (Atlas\Symfony\DependencyInjection\AtlasExtension).
- symfony/http-kernel: Required by the Symfony bundle (Atlas\Symfony\AtlasBundle).
- symfony/security-http: To use the Symfony integration (Atlas\Symfony): the AtlasAuthenticator security authenticator and AtlasUser.
Provides
None
Conflicts
None
Replaces
None
README
The official PHP backend SDK for Atlas — the secret-key Backend API client plus local session-token/JWT verification. It covers every Backend API operation and the platform-key API, with namespaces and method names that mirror the TypeScript SDK.
- PSR-18 / PSR-17 transport. Runs on Guzzle out of the box, or any PSR-18 client you inject. No network is required to unit-test code that uses it.
- Local JWT verification.
SessionVerifierverifies session tokens against cached JWKS with no call to Atlas on the hot path — the same design as the TypeScript verifier.
Requires PHP 8.1+.
Install
composer require atlas-auth/atlas-php
The Backend API client
Construct Atlas\Client with your instance secret key. Guzzle is
auto-discovered, so the zero-config path needs nothing else:
use Atlas\Client; $atlas = new Client('sk_live_…'); $user = $atlas->users->get('user_123'); $page = $atlas->users->list(['limit' => 50]); foreach ($page['data'] as $u) { /* … */ }
Every resource method returns the decoded JSON as an associative array. Writes take an associative body and an optional idempotency key:
$org = $atlas->organizations->create(['name' => 'Acme', 'slug' => 'acme'], idempotencyKey: 'req-1'); $atlas->sessions->revoke('sess_123');
Pagination
use Atlas\Pagination; foreach (Pagination::iterate([$atlas->users, 'list']) as $user) { // walks every cursor page }
Error handling
Any non-2xx response throws the most specific Atlas\Exception\* subclass, all
extending AtlasApiException:
use Atlas\Exception\NotFoundException; use Atlas\Exception\AtlasApiException; try { $atlas->users->get('user_missing'); } catch (NotFoundException $e) { // 404 } catch (AtlasApiException $e) { $e->getStatus(); // int HTTP status $e->getErrorCode(); // stable machine code, e.g. "LAST_ADMIN" $e->hasCode('LAST_ADMIN'); $e->getErrors(); // the full error envelope }
TransportException is thrown when no response is received at all;
ConfigException on a bad configuration (e.g. an empty key).
Atlas\ErrorCode names the codes most worth branching on — UNKNOWN_FIELD,
EMAIL_DELIVERY_NOT_CONFIGURED, EMAIL_PROVIDER_REQUIRED, EMAIL_PROVIDER_IN_USE,
PLAN_IN_USE, WRONG_KEY_SURFACE, INVALID_KEY and more:
use Atlas\ErrorCode; if ($e->hasCode(ErrorCode::EMAIL_DELIVERY_NOT_CONFIGURED)) { /* tell an admin */ }
Strict request bodies
By default a write that includes a field the endpoint does not accept still
succeeds; the field is ignored and named in the Atlas-Unknown-Fields response
header. Turn on strict bodies to have such a write refused instead (422
UNKNOWN_FIELD, with param naming the field), and/or get told about ignored
fields:
use Atlas\UnknownFields; $atlas = new Client( 'sk_live_…', strict: true, // sends Atlas-Strict: true on every write onUnknownFields: function (UnknownFields $w) use ($logger): void { $logger->warning('Atlas ignored ' . implode(', ', $w->fields) . " on {$w->method} {$w->path}"); }, ); // Override for one call: $atlas->withStrict(false)->users->update('user_123', ['legacy_field' => 'x']);
withStrict(false) sends Atlas-Strict: false, which opts a request out of an
instance-wide strict default; withStrict(null) sends no header.
Check your setup
$report = $atlas->instance->preflight(); if (!$report['ok']) { foreach ($report['findings'] as $f) { echo "{$f['severity']} {$f['code']}: {$f['message']}\n fix: {$f['fix']}\n"; } }
A warning or info finding you know about and accept can be acknowledged with a reason (and an
optional expires_at). It is still listed, with acknowledged; skip those when counting. Errors
are never acknowledged (422):
$atlas->instance->setPreflightAck('webhook_endpoint_off_origin', ['reason' => 'Our relay host']); $atlas->instance->listPreflightAcks(); $atlas->instance->deletePreflightAck('webhook_endpoint_off_origin');
Sign-in page design
// Upload an image (PNG, JPEG, GIF or WebP; the type is detected from the bytes) $asset = $atlas->design->uploadAsset(file_get_contents('hero.jpg'), 'background'); $atlas->design->update([ 'preset' => 'glass', 'palette' => ['primary' => '#4f46e5'], 'background' => ['image' => ['assetId' => $asset['asset_id'], 'fit' => 'cover']], ]); $atlas->design->preview(['preset' => 'midnight']); // compile without saving $atlas->organizations->updateDesign('org_123', ['palette' => ['primary' => '#0f766e']]); $atlas->design->catalog(); // presets, fonts, CSS variables
The design array is fully typed (AtlasDesign) for static analysers; see
Atlas\Resources\Design.
Cookie consent
use Atlas\Resources\Instance; $atlas->instance->setCookieConsent([ 'script_url' => 'https://cmp.example.com/loader.js', 'site_id' => 'abc123', // Optional "cookie settings" link on the hosted pages (null or omitted = no link). 'settings_link' => ['label' => 'Cookie settings', 'preset' => Instance::COOKIE_SETTINGS_LINK_PRESET_COOKIEBOT], ]); $consent = $atlas->instance->getCookieConsent(); $consent['settings_link']['rendered_attributes']; // your attributes plus the preset's trigger $atlas->instance->deleteCookieConsent();
A settings link needs a preset (cookiemunch, cookiebot, onetrust or
custom) or at least one entry in attributes (data-*, aria-*, id,
class, or an https / #fragment href; at most 10).
Messaging providers
$atlas->messaging->deleteChannel('sms'); $atlas->messaging->deleteChannel('email', force: true);
Removing the email provider is refused with a ConflictException
(EMAIL_PROVIDER_IN_USE, with meta.features listing the email-dependent
features that are on) while nothing else would deliver email; force: true
removes it anyway.
OAuth clients
$atlas->oauthClients->update($clientId, ['skip_logout_confirmation' => true]);
For a first-party client, skip_logout_confirmation makes RP-initiated logout
(/oauth2/logout with this client's client_id and an exactly registered
post_logout_redirect_uri) end the session and redirect straight back with
state, without the confirmation page. A third-party client is refused (422).
Sign a user out of your app and of Atlas from your backend with the id_token's sid. This
revokes the client's grants under that sid, ends the Atlas session and queues a back-channel
logout to every other app signed in through it; no browser redirect to /oauth2/logout is
needed (needs oauth_clients:write and sessions:write):
$out = $atlas->oauthClients->logout($clientId, $claims['sid']); $out['session']; // "revoked" or "already_ended"
refresh_touches_session (first-party) keeps the Atlas session alive on every refresh and makes
the refresh fail (invalid_grant) once that session has ended. add_redirect_origins: true on
create / update also adds the redirect URIs' origins to allowed_origins; the response lists
them in added_origins.
A generated secret can be fetched once more within 10 minutes (client_secret_reveal on the
create / rotate answer says where and until when); send client_secret on create to choose your
own instead. require_verified_email: true issues no authorization code to a user whose primary
email is not verified:
$secret = $atlas->oauthClients->revealSecret($client['id'])['client_secret'];
Rotating a secret keeps the previous one working for overlap_seconds (default 24 hours, at
most 7 days), so you can deploy the new secret without downtime. Pass 0 to revoke the old one
now, for example after a leak:
$rotated = $atlas->oauthClients->rotateSecret($clientId, ['overlap_seconds' => 3600]); $rotated['client_secret']; // shown once $rotated['previous_secret_expires_at']; // epoch ms, or null when the old secret already stopped
Platform API: Sign in with Atlas apps
Construct the client with a platform key (psk_…) to manage apps for your account:
$platform = (new Client(getenv('ATLAS_PLATFORM_KEY')))->platform; $app = $platform->atlasIdApps->create([ 'name' => 'Acme', 'redirect_uris' => ['https://acme.com/auth/callback'], 'post_logout_redirect_uris' => ['https://acme.com/'], 'backchannel_logout_uri' => 'https://acme.com/auth/backchannel-logout', 'branding' => ['theme' => 'auto', 'accent_color' => '#4f46e5', 'copy' => ['sign_in_title' => 'Sign in to {app}']], ]); $app['client_secret']; // shown once (confidential apps) $platform->atlasIdApps->rotateSecret($app['id']); $logo = $platform->designAssets->upload(file_get_contents('logo.png'), 'logo'); $platform->atlasIdApps->update($app['id'], ['branding' => ['design' => ['logo' => ['assetId' => $logo['asset_id']]]]]); $platform->apps->list(); // apps (instances) your platform provisions
Test and review accounts. Needs the platform scope platform:atlas_id_apps:provision (or the per-app scope atlas_id_apps:<app id>:provision). The password comes back once, as password; pass a secretRecipient to get sealed_password instead.
$acct = $platform->atlasIdApps->provisionedAccounts->create($app['id'], ['email' => 'reviewer@example.com', 'label' => 'app-store reviewer']); $acct['password']; // shown once; do not log it $platform->atlasIdApps->provisionedAccounts->rotatePassword($app['id'], $acct['id'], null, 'x25519:…'); // sealed_password $platform->atlasIdApps->provisionedAccounts->list($app['id']); $platform->atlasIdApps->provisionedAccounts->remove($app['id'], $acct['id']); $d = $platform->domains->claim('example.com'); // publish the TXT record in $d['verification'] $platform->domains->check($d['id']); // verify; also list() and remove() $platform->atlasIdApps->update($app['id'], ['test_mode' => true, 'provisioned_verified_domains_only' => true]);
test_mode lets qa+atlas_test@your-verified-domain sign in with the fixed code 424242. Errors: ErrorCode::TEST_ACCOUNT_RESTRICTED (403), PROVISIONED_ACCOUNT_LIMIT_REACHED (409), DOMAIN_NOT_VERIFIED (422).
Domain moves, logout fan-out and managed email
// Move every URL from the old origin to the new one. dry_run first shows the diff. $preview = $atlas->instance->moveOrigin(['from' => 'https://old.example.com', 'to' => 'https://example.com', 'dry_run' => true]); foreach ($preview['changes'] as $c) { echo "{$c['object']} {$c['field']}\n"; } // Send the same logout_token your OIDC apps get to another service. $atlas->logoutNotifyEndpoints->create(['url' => 'https://api.example.com/atlas/logout']); // Set your domain up on your own Resend or Brevo account, then publish the returned DNS records. $managed = $atlas->messaging->createManagedEmail([ 'from_address' => 'Acme <no-reply@example.com>', 'transport' => 'resend', 'config' => ['api_key' => getenv('RESEND_API_KEY')], ]); // A development instance can send sign-in emails through your provider without a verified domain. $atlas->messaging->enableDevelopmentEmail(['provider_id' => 'mp_123']); $atlas->messaging->disableDevelopmentEmail();
Webhook::verify accepts an atlas-signature header that carries several signatures separated
by spaces (v1,<sig> v1,<sig>). While an endpoint secret rotates, Atlas may sign each delivery
with the old and the new secret; the delivery verifies when any one signature matches.
A rotation stops the old secret at once by default. Pass overlap_seconds (at most 7 days) to
keep it signing each delivery next to the new one while you deploy; choose an overlap only once
every receiver accepts several signatures:
$rotated = $atlas->webhooks->endpoints->rotateSecret($endpointId, ['overlap_seconds' => 3600]); $rotated['previous_secret_expires_at']; // epoch ms, or null when the old secret already stopped
Trusted reverse proxy
If a reverse proxy serves the Frontend API from your own origin, give it a proxy secret. It sends
the secret in X-Atlas-Proxy-Secret and the browser's IP in X-Atlas-Client-IP; Atlas then uses
that IP for rate limits, lockout, risk signals and audit logs.
$created = $atlas->instanceSecurity->createProxySecret(); $created['secret']; // shown once; store it in your proxy's environment $atlas->instanceSecurity->getProxySecret(); // set, created_at, previous_expires_at (never the secret) $atlas->instanceSecurity->createProxySecret(['overlap_seconds' => 3600]); // rotate; the old one works 1 hour $atlas->instanceSecurity->deleteProxySecret();
Platform rotations take an overlap too: $platform->apps->rotateInstanceSecret($appId, $instanceId, ['overlap_seconds' => 0])
(default 72 hours), $platform->atlasIdApps->rotateSecret($id, ['overlap_seconds' => 3600]) (default 24 hours) and
$platform->atlasIdApps->rotateWebhookSecret($id, ['overlap_seconds' => 3600]) (default 0).
With a platform key, $platform->atlasIdApps->findUsers($id, $email) answers a privacy request for one app,
revokeGrant($id, $grantId) revokes one person's approval, and $platform->atlasIdPublisher claims and verifies
the publisher name shown on consent screens.
Referral attribution
With referral attribution on, Atlas reports every interactive sign-up to your referral platform before
user.created is sent. Users carry referral (where attribution stands and, once attributed, who referred them;
null when never reported), referral_code and referral_link. referral['status'] is pending, attributed,
none, failed, held or flagged; the platform's own status is referral['referral_status'].
$atlas->referrals->update([ 'enabled' => true, 'provider' => 'webhook', // the Atlas referral protocol; or 'referalli' (the legacy id 'referally' is still accepted) or another built-in adapter 'endpoint_url' => 'https://referrals.example.com/atlas', 'signing_secret' => getenv('REFERRAL_SIGNING_SECRET'), 'security' => ['on_high_risk' => 'hold'], ]); $atlas->referrals->test(); // ['ok' => true, 'http_status' => 404, ...] $failed = $atlas->referrals->listCalls(['status' => 'failed', 'limit' => 20]); $atlas->referrals->retryCall($failed['data'][0]['id']); $atlas->users->update('user_123', ['referral_code' => 'ada-1']); // 409 REFERRAL_CODE_TAKEN if held $atlas->users->flagReferral('user_123', 'self-referral'); $atlas->referrals->simulate('ACME10'); // a made-up user; nothing is stored $atlas->referrals->retryFailed(['error_code' => 'signature_rejected']); // after pasting the new secret $atlas->referrals->listCalls(['limit' => 1000, 'starting_after' => 'rfc_123', 'error_code' => 'timeout']); $job = $atlas->referrals->backfill(['since' => '2026-01-01', 'code_metadata_path' => 'public_metadata.referral_code']); $atlas->referrals->getBackfill($job['id']); // running, then completed $atlas->referrals->getForUser('user_123'); // the stored result, no call to the platform $atlas->users->setExternalId('user_123', 'cust_42'); // forwarded to the platform as an alias
Enabling without what the provider needs is 422 REFERRALS_NOT_CONFIGURED. When the platform has not answered by the
time user.created is sent, referral['status'] is pending and the user.referral_resolved webhook follows with the
final user (attributed, none or failed). user.referral_attributed still fires for a late attribution.
Two-way sync: a provider with the inbound capability sends referral changes made after sign-up back to Atlas. The
settings report inbound (url to give the platform, secret_set, endpoint_id, and last_event with at, type,
outcome). Send inbound_secret (write-only, 16-512 characters) to store the platform's event secret and
inbound_endpoint_id for its subscription id; null clears either.
Browser capture: browser_sources lists the referral programs whose script or cookie the browser reads (null goes
back to the provider's default, reported by browser_sources_default), and consent_provider picks the consent
manager that decides (auto, one manager, or app).
To give each user your own id before the first referral call, turn on the before_user_create hook. Atlas then sends
your endpoint a signed POST (verify it with Webhook::verify) of type user.before_create for every self-service
sign-up, and stamps the external_id and metadata you answer:
$atlas->userHooks->updateBeforeUserCreate([ 'enabled' => true, 'url' => 'https://app.example.com/hooks/before-create', 'signing_secret' => getenv('BEFORE_CREATE_SECRET'), ]); $atlas->userHooks->testBeforeUserCreate(); // ['ok' => true, 'answer' => [...]]
API key spend caps
Give an end-user API key caps in your own units, then meter what your API consumed against them. A refused debit records nothing and is not an exception, so you enforce it inside your own transaction:
use Atlas\ErrorCode; $key = $atlas->apiKeys->create([ 'subject_type' => 'user', 'subject_id' => 'user_123', 'name' => 'CI', 'limits' => [ ['unit' => 'requests', 'amount' => 10000, 'period' => 'day'], // day/month are UTC calendar windows ['unit' => 'usd_cents', 'amount' => 500, 'period' => 'request'], // caps a single debit; "total" never resets ], ]); $debit = $atlas->apiKeys->recordUsage($key['id'], 1, 'requests', idempotencyKey: $requestId); if (!$debit['allowed']) { // $debit['code'] === ErrorCode::LIMIT_EXCEEDED return new Response(429, body: 'quota exceeded'); } $atlas->apiKeys->getUsage($key['id']); // caps with used/remaining/resets_at + raw counters $atlas->apiKeys->update($key['id'], ['limits' => null]); // remove every cap
Reusing an idempotency key replays the first outcome (idempotent_replay: true) without counting again; reusing it with a different unit or amount throws a ConflictException with IDEMPOTENCY_CONFLICT. apiKeys->verify() also returns the key's name, scopes, constraints, limits and usage. Subscribe to the api_key.created, api_key.updated and api_key.limit_reached webhooks to mirror keys and react to a filled cap.
Session keep-alive
If your app loads Atlas on only some pages, keep the session's inactivity window moving from your backend:
$session = $atlas->sessions->touch($sessionId); $session['extended']; // false when a touch within the last minute already moved it $session['idle_expires_at']; // epoch ms the session ends from inactivity
It never extends past the absolute expiry and never revives an ended session (ConflictException with SESSION_REVOKED, SESSION_IDLE_EXPIRED or SESSION_EXPIRED). Calling it on every request is safe.
users->create() and importExport->importUsers() accept 'notify' => false (the default), which guarantees the user is sent no email, SMS or push as a consequence of the create.
Application webhooks
Send your app's own events to endpoints your customers (organizations) register. Atlas signs each delivery (Standard Webhooks), retries, tracks health and keeps the delivery log.
$atlas->appEvents->types->create(['type' => 'call.completed', 'description' => 'A call finished.']); $atlas->appEvents->endpoints->create($orgId, ['url' => 'https://hooks.example.com/atlas', 'enabled_events' => ['call.completed']]); $atlas->appEvents->publish(['organization_id' => $orgId, 'type' => 'call.completed', 'data' => ['call_id' => $id], 'idempotency_key' => $id]); // The receiving side (any Standard Webhooks library works too): $result = \Atlas\Webhook\Webhook::verifyStandard(file_get_contents('php://input'), getallheaders(), 'whsec_...');
Browser tickets and test sign-in URLs
// A one-time ticket that signs a browser in as the user (support handoffs, E2E). Replaces signInTokens. $ticket = $atlas->sessions->createBrowserTicket('user_123', 'https://app.example.com/', 120); $ticket['url']; // open it in the browser // Development instances only: a ready sign-in URL for tests. $url = $atlas->testing->createSignInUrl('user_123', 'http://localhost:3000/')['url'];
A ticket is single-use. On development instances only, a repeat exchange of the same ticket from the same client within 10 seconds returns the first answer (so a double-mounted page does not fail).
$atlas->instance->getSignInWithAtlas() / updateSignInWithAtlas(), getHostedPages() /
updateHostedPages() and $atlas->privacy->getIntegration() / connectIntegration() cover the
instance's "Sign in with Atlas", hosted-page redirects and the one-call privacy integration.
All namespaces
users, sessions, organizations (with memberships, invitations,
domains, groupRoles, auditStreams, endUsers, readerDomain, plus
settings, design and branding), roles, permissions, oauthClients,
resourceServers, ssoConnections, scimTokens, domains, waitlist,
allowlist, blocklist, attackProtection, actorTokens, invitations,
webhooks, signInTokens, auditLogs, auditEvents, jwtTemplates,
apiKeys, oauthProviders, ssoOnboarding, scimProvisioning, fga,
openFga, rateLimitPolicy, riskBasedMfa (with listEvents, eventMetrics, eventFeedback, fraudLookup), botSignals, networkAcls,
managedWaf, logStreams, branding, design, emailTemplates,
smsTemplates, localizations, actions, billing, messaging,
importExport, dataSubjectRequests, radiusClients, ltiPlatforms,
instance (incl. preflight and cookie consent), instanceSecurity, tokens,
emailAddresses, organizationSettingsSchema, entitlementOverrides,
machines (incl. enrolment tokens), connectors, directory, diagnostics,
configChangelog, events, usage, legacyAuthConnection, mcp,
mcpConnector, notifications (with categories and templates),
orgLdapConnections, pca, redirectUrls, sessionExchangeApps, tickets,
captcha, appEvents (with types and endpoints), testing (development-instance helpers), and platform (apps,
atlasIdApps, designAssets; needs a platform key).
Anything not wrapped can be called directly with $atlas->http->request('GET', '/v1/…').
Credentials, options and helpers
$who = $atlas->whoami->get(); // which key am I, with which scopes $key = $atlas->secretKeys->create(['name' => 'ci', 'expires_at' => time() * 1000 + 86400000]); // Preview a write without saving it, and follow one operation across systems $atlas->withOptions(dryRun: true)->webhooks->endpoints->create(['url' => 'https://app.example.com/hooks']); $atlas->withOptions(traceparent: \Atlas\Traceparent::generate())->users->ban('user_1'); // Keys in either format \Atlas\KeyFormat::isSecretKey('atlas_live_sk_...'); // true; 'sk_live_...' too // Webhooks: pass the endpoint URL to also check v2 signatures $result = \Atlas\Webhook\Webhook::verify($body, getallheaders(), $secret, url: 'https://app.example.com/hooks');
A CLI or CI job without a secret key uses Atlas\Auth\OperatorLogin (device flow, token exchange, partner-link client_credentials). A back-channel logout route is one call: $logout->handle($_POST['logout_token'] ?? '', fn ($claims) => ...) returns the status and headers to answer with.
Partner links without a pasted key. A partner product calls (new \Atlas\PartnerLink\PartnerLinkRequests())->create([...]); you listRequests() and approveRequest($id) / denyRequest($id); Atlas delivers the credentials to the partner's callback, which checks them with PartnerLinkRequests::verifyDelivery($body, $headers, $requestSecret). setCertificates($linkId, [...]) locks a link to client certificates (mTLS), and requestOutbound($partner, [...]) asks a partner for a link.
Keys. secretKeys->create() takes owner, expires_at (one year by default, never longer) and allowed_origins (a call from another browser origin is refused with ORIGIN_NOT_ALLOWED: $e->isOriginNotAllowed()).
Replay what an endpoint missed. webhooks->endpoints->replay($id, ['since' => 'failing_since']) starts from the beginning of the last failure streak (endpoints report last_failing_since, which survives recovery).
OIDC relying parties. \Atlas\OidcClaims::organization($claims), organizations($claims) (scope organizations) and sso($claims) read the org_*, orgs and sso_* claims; authorizeUrlWithOrganization($url, 'acme') pins the organization. (new \Atlas\AtlasIdTokens($frontendApi, $publishableKey))->fetch('referalli', $accessToken) is the browser atlas_id_tokens route for Sign in with Atlas users.
Policy acceptance
Terms of Service, privacy policies and any other policy your users accept at sign-up (and again
at sign-in when a new version takes effect). A version goes draft, published (with an
effective_at), retired; each acceptance is kept as evidence.
$atlas->policies->create(['key' => 'tos', 'title' => 'Terms of Service', 'applies_to' => 'sign_in_and_sign_up']); $atlas->policies->versions->create('tos', ['version' => '2026-10-24', 'content_markdown' => "# Terms\n..."]); $atlas->policies->versions->publish('tos', '2026-10-24', ['effective_at' => '2026-11-01T00:00:00Z']); $atlas->policies->listAcceptances('tos', ['version' => '2026-10-24', 'limit' => 50]); $csv = $atlas->policies->exportAcceptancesCsv('tos'); $atlas->policies->forUser('user_123'); // standing on every policy, incl. needs_acceptance $atlas->policies->settings->update(['session_claim' => true]); // put `pol` on session tokens
With the session claim on, $result->policies() / $result->acceptedPolicyVersion('tos') on a
VerifyResult read the pol claim. The user.policy_accepted and policy.version_published
webhook events have the constants Constants::WEBHOOK_EVENT_USER_POLICY_ACCEPTED and
Constants::WEBHOOK_EVENT_POLICY_VERSION_PUBLISHED. users->recordConsent() takes the
acceptance evidence (policy_version_id, content_sha256, display, scrolled_to_end,
view_ms, locale).
Serving policies from your own system (policy source)
Point Atlas at a CMS or service with $atlas->policySource->update(['enabled' => true, 'endpoint_url' => ..., 'signing_secret' => ...]) (the secret is write-only), then test() /
sync(). In your service, verify Atlas's signed GET {endpoint}/policies[?key=...] and answer a
feed:
use Atlas\PolicySource\PolicySourceProtocol; $v = PolicySourceProtocol::verifyFeedRequest($_SERVER['REQUEST_METHOD'], $_SERVER['REQUEST_URI'], getallheaders(), $secret); if (!$v->verified) { http_response_code(401); return; } header('Content-Type: application/json'); echo json_encode(['object' => 'policy_list', 'policies' => buildPolicies($v->key)]); // one policy when $v->key is set // When a policy changes, tell Atlas (it re-fetches; nothing in the event is trusted as content): $signed = PolicySourceProtocol::signEvent(['type' => 'policy.published', 'key' => 'tos', 'version' => '2026-10-24'], $secret); // POST $signed->body to the webhook_url with Content-Type: application/json and the $signed->headers
Verifying session tokens
Atlas\Verify\SessionVerifier verifies a session JWT locally: RS256 against
cached JWKS, exp/nbf with a 5-second clock skew, the issuer, and an optional
azp allowlist. It never calls Atlas on the hot path — the revocation window is
bounded by the 60-second token lifetime instead.
use Atlas\Verify\SessionVerifier; $verifier = new SessionVerifier( jwksUrl: 'https://auth.yourdomain.com/.well-known/jwks.json', issuer: 'https://auth.yourdomain.com', authorizedParties: ['https://app.example.com'], // optional azp allowlist ); // Verify whatever the request carries (Authorization header or __session cookie). $result = $verifier->authenticateRequest(getallheaders()); if (!$result->ok) { http_response_code(401); exit; } $userId = $result->claims['sub']; // Authorization helpers, bound to the verified claims: if ($result->has(['permission' => 'billing:read'])) { /* … */ } $result->protect(['role' => 'admin']); // throws Atlas\Verify\ForbiddenException on failure
authenticateRequest() accepts a PSR-7 request or a plain, case-insensitive
array<string,string> of headers. verify(string $token) is the direct form.
The online slow path
For the handful of operations where a 60-second revocation window is
unacceptable (deleting an account, moving money), verifyOnline() asks Atlas
whether the session is still live. It costs a round trip and fails closed — an
outage returns invalid, never a stale local pass — so it is not the default.
$verifier = new SessionVerifier( jwksUrl: '…', issuer: '…', secretKey: 'sk_live_…', // required for verifyOnline bapiBaseUrl: 'https://api.atlasauth.net', // required for verifyOnline ); $result = $verifier->verifyOnline($token);
JWKS caching
Keys are cached in-process. A token carrying an unknown kid triggers at most
one JWKS refetch per minute, so a flood of random kid values cannot be turned
into a flood of outbound requests. Key rotation is picked up automatically with
no deploy.
Back-channel logout
When a user signs out, Atlas POSTs a signed logout_token to your app's
backchannel_logout_uri (Sign in with Atlas apps and session-exchange apps).
Verify it, end the matching local sessions, and answer 200:
use Atlas\Verify\BackchannelLogout; use Atlas\Verify\InvalidLogoutTokenException; $logout = new BackchannelLogout( issuer: 'https://atlasauth.net', // your instance's frontend API origin for session-exchange apps jwksUrl: 'https://atlasauth.net/.well-known/jwks.json', clientId: getenv('ATLAS_CLIENT_ID'), // the token's audience jtiStore: $myJtiStore, // optional: refuse replays (see below) ); // POST /auth/backchannel-logout — plain PHP; adapt to your framework's request/response. header('Cache-Control: no-store'); try { $claims = $logout->verify($_POST['logout_token'] ?? ''); if ($claims->sid !== null) { $sessions->deleteByAtlasSessionId($claims->sid); // one session } else { $sessions->deleteByAtlasUserId($claims->sub); // every session of the user } http_response_code(200); } catch (InvalidLogoutTokenException $e) { http_response_code(400); }
The verifier checks the RS256 signature, iss, aud, iat, exp, jti, the
back-channel logout events claim, that sid or sub is present, that there
is no nonce, and the logout+jwt type. To refuse replays, implement
Atlas\Verify\JtiStore (one method, add(string $jti, int $ttlSeconds): bool,
returning false for an id it has seen) over a store all your PHP processes
share — Redis SET key 1 NX EX ttl or a unique database column.
InMemoryJtiStore is included for long-running workers and tests.
Laravel
The SDK ships an optional, auto-discovered integration under Atlas\Laravel
(install illuminate/support + illuminate/http — the base SDK requires
neither). Atlas\Laravel\AtlasServiceProvider binds a container singleton
SessionVerifier — so the in-process JWKS cache is reused across requests — and
registers two route-middleware aliases. Publish the config and set your keys in
.env:
php artisan vendor:publish --tag=atlas-config
# .env
ATLAS_JWKS_URL=https://auth.yourdomain.com/.well-known/jwks.json
ATLAS_ISSUER=https://auth.yourdomain.com
Then gate routes. atlas.auth verifies the Authorization: Bearer header (or
the __session cookie), 401s when absent/invalid, and on success attaches the
verified result to $request->attributes->get('atlas') and $request->user().
atlas.permission reads the org role / permissions straight off the token:
Route::get('/billing', BillingController::class) ->middleware(['atlas.auth', 'atlas.permission:billing:read']); // a role: prefix requires an org role, e.g. 'atlas.permission:role:admin' // In the controller: $claims = $request->user()->claims; // sub, sid, org_role, … $request->user()->protect(['role' => 'admin']); // throws ForbiddenException → 403
Because the SDK is built on PSR-18/PSR-17, you can inject Laravel's HTTP client or any other conforming implementation instead of Guzzle by passing it to the constructor.
Symfony
An optional Symfony integration lives under Atlas\Symfony (install
symfony/security-http + symfony/http-kernel). Register the bundle and point a
firewall at the authenticator:
// config/bundles.php Atlas\Symfony\AtlasBundle::class => ['all' => true],
# config/packages/security.yaml security: firewalls: api: pattern: ^/api stateless: true custom_authenticators: - Atlas\Symfony\AtlasAuthenticator
AtlasAuthenticator claims any request carrying a Bearer token or __session
cookie, verifies it locally, and returns a SelfValidatingPassport whose user is
an Atlas\Symfony\AtlasUser — identifier sub, roles ROLE_USER plus
ROLE_<ORG_ROLE>, full claims via claims(). A missing/invalid token is a 401.
Configure it with the ATLAS_* environment variables, or under an atlas key.
Better Auth bridge (Laravel, Symfony)
During a migration from Better Auth, both integrations ship the same bridge as the PSR-15
BetterAuthBridgeMiddleware, built on SessionBridge: GET / HEAD only; a request with a Better Auth
session cookie and no Atlas session is exchanged once and answered 303 back to the same URL with
__session / __atlas_rt set; if a second factor is owed it redirects to your sign-in URL with
__atlas_attempt / __atlas_status; Better Auth cookies are cleared only on a final refusal (a closed
bridge, a 429 or an outage keeps them). The end user's IP is the framework's own client IP, so your
trusted-proxy setup applies.
Laravel: set ATLAS_SECRET_KEY and (optionally) ATLAS_BETTER_AUTH_SIGN_IN_URL,
ATLAS_BETTER_AUTH_COOKIE_PREFIX, ATLAS_BETTER_AUTH_COOKIE_NAME, ATLAS_BETTER_AUTH_COOKIE_DOMAIN
(the atlas.better_auth_bridge config key), then put the atlas.better_auth_bridge alias ahead of
atlas.auth, or add Atlas\Laravel\BetterAuthBridge as global middleware (before cookie encryption is fine:
it reads the raw Cookie header):
Route::middleware(['atlas.better_auth_bridge', 'atlas.auth'])->group(/* ... */);
Symfony: enable it under the atlas key; it registers Atlas\Symfony\BetterAuthBridgeSubscriber on
kernel.request (priority 64, ahead of the firewall):
# config/packages/atlas.yaml atlas: secret_key: '%env(ATLAS_SECRET_KEY)%' better_auth_bridge: enabled: true sign_in_url: /sign-in # optional # cookie_prefix, cookie_name, cookie_domain, secure: false (plain-http dev only)
PSR-15 middleware (Slim, Mezzio, any pipeline)
For a framework without a bespoke integration — or none at all — the SDK ships a
standard PSR-15 middleware under Atlas\Psr15 (install psr/http-server-middleware).
It wraps the same framework-free SessionVerifier, so there is no second copy of
the JWT/JWKS crypto. Verification is local — no network on the hot path.
use Atlas\Psr15\AtlasAuthenticationMiddleware; use Atlas\Verify\SessionVerifier; $verifier = new SessionVerifier( jwksUrl: getenv('ATLAS_JWKS_URL'), issuer: getenv('ATLAS_ISSUER'), ); // Slim 4: add it ahead of the routes it should protect. $app->add(new AtlasAuthenticationMiddleware($verifier));
It reads an Authorization: Bearer <jwt> header or the __session cookie off the
PSR-7 request (header first). On success it attaches the verified result as the
atlas request attribute and calls the next handler; a missing/invalid token
short-circuits with a 401 JSON body and the handler never runs.
$result = $request->getAttribute('atlas'); // Atlas\Verify\VerifyResult $result->protect(['permission' => 'billing:write']); // throws ForbiddenException on fail $userId = $result->claims['sub'];
The attribute name is the fourth constructor argument (default atlas); the 401
response is built with auto-discovered Guzzle PSR-17 factories, or pass your own
ResponseFactoryInterface / StreamFactoryInterface as the second and third
arguments.
Testing
The SDK ships a PHPUnit suite that runs entirely against a mocked PSR-18 client — no network. Run it with:
composer install
composer test
License
MIT — see LICENSE.