New here? Start with the introduction, or the Univeros origin story.
This is where the rubber meets the road. In part 1 I showed you the one-line install. Now, let me walk you through the actual authentication flow, end to end, with real requests and real responses. Everything below was captured from a live run of the module against a Postgres database, so what you see is what you get.
Step 1: Register a User
Registration takes an email, a password, and an optional display name:
curl -s https://api.example.com/v1/auth/register \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct horse battery staple"}'{ "message": "If the address can be registered, a verification message has been sent." }Now, look closely at that response. It is a generic 202, and you get the exact same message regardless of the address already being taken or not. This is not being lazy, it’s on purpose. If registration simply said "this email is already in use," you would have just built an account-enumeration oracle: anyone could probe your endpoint to learn which emails have accounts. Polaris refuses to be that oracle. A real verification email goes out only when the address is genuinely new.
Step 2: Verify the Email
The verification email carries a single-use token. The verification tokens are stored only as keyed HMACs, never in plaintext, so a database leak would not hand out working links. The client posts the token back:
curl -s https://api.example.com/v1/auth/email/verify \
-H 'Content-Type: application/json' \
-d '{"token":"<token from the email>"}'{ "message": "Email verified." }This is idempotent: verifying an already-verified token still returns 200. An unknown or expired token gets a 400.
Step 3: Log in
Now for the interesting part. Here is the actual login domain - the real source (not a trimmed-for-the-blog version):
final class LoginDomain extends AuthDomain
{
public function __construct(private readonly LoginService $login)
{
}
public function __invoke(InputCollection $input): PayloadInterface
{
$email = trim((string) $input->get('email', ''));
$password = (string) $input->get('password', '');
if ($email === '' || $password === '') {
return $this->unprocessable(['An email and password are required.']);
}
if (!$this->isEmail($email)) {
return $this->unprocessable(['A valid email address is required.']);
}
try {
$result = $this->login->login($email, $password, $this->client($input));
} catch (EmailNotVerifiedException) {
return $this->respond(403, [
'error' => 'email_unverified',
'message' => 'Verify your email address before logging in.',
'resend' => '/auth/email/verify/resend',
]);
} catch (AccountDisabledException) {
return $this->respond(403, ['error' => 'account_disabled', 'message' => 'This account is disabled.']);
} catch (InvalidCredentialsException) {
return $this->respond(401, ['error' => 'invalid_credentials', 'message' => 'Invalid email or password.']);
}
if ($result instanceof MfaChallengeResult) {
return $this->respond(200, $this->mfaBody($result));
}
return $this->respond(200, $this->body($result));
}
}There are two things worth pausing on here. First, a wrong password, an unknown account, and an account currently locked out - all collapse into the identical generic 401 invalid_credentials. You cannot use the login endpoint to fingerprint which emails exist. Second, if the user has a confirmed MFA factor, login does not return tokens at all, it returns a challenge (more about that path in part 3). For a user with no MFA, you get tokens straight away:
curl -s https://api.example.com/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"email":"[email protected]","password":"correct horse battery staple"}'{
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1Ni...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "jpSIunl4OSVEi175Fj...",
"user": {
"id": "019ee088-0581-73b1-89ee-e1659e670100",
"email": "[email protected]",
"email_verified": true
}
}
}The Access Token, Decoded
The access token is a JWT signed with an asymmetric key (RS256 by default, or EdDSA if you prefer). Decode the payload and you’ll find a claim set like this:
{
"iss": "https://auth.example.com",
"iat": 1765432100,
"exp": 1765433000,
"sub": "019ee088-0581-73b1-89ee-e1659e670100",
"jti": "019ee088-0727-7518-9e22-6e8bb6ff2e3e",
"sid": "019ee088-071d-799a-b398-c7603a2c360e",
"org": null,
"roles": [],
"email_verified": true,
"mfa": false,
"amr": ["pwd"],
"auth_time": 1765432100
}A few of these earn their keep:
sidis the session id, which is also the refresh-token family. One family = one session = one device. This is the hook that lets you list and revoke individual devices.mfa,amr, andauth_timedescribe how the user proved who they are.amrlists the methods (["pwd"]for password only,["pwd","otp"]after MFA).auth_timeis when they last did a strong auth, and it powers step-up in part 3.orgisnullandrolesis empty until the user scopes the session to an organization. That whole story is part 4.- There is no flat
scopeclaim by default. The token carriesrolesonly and Polaris resolves the real permissions server-side. If you want the permission list embedded for a cheap downstream check, flipaccess_token.embed_scopeon and ascopearray joins the claim set.
Your other services verify these tokens with just the public key, published at a standard JWKS endpoint:
curl -s https://api.example.com/v1/auth/.well-known/jwks.json{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "981677f7edd1cb0593ee682b1b80b14e76a1801b48458e5a9a34f0ff34fc42a4",
"n": "lzAqP8aVH28qMzG3qpceLHuYcA65O3mn2YggSoXp-7KVi4-x...",
"e": "AQAB"
}
]
}Key rotation is built in. When you rotate signing keys, the retiring public key stays in the JWKS for one access-token lifetime so in-flight tokens keep verifying, then it drops off. No thundering herd of 401s at the moment you rotate.
Rotating Refresh Tokens, and the Theft Trap
This is the part people get wrong when they hand-roll auth, so it deserves its own section.
The access token is short-lived (15 minutes by default). The refresh token is the long-lived credential that mints new access tokens. Every time a client refreshes, it hands in its current refresh token and gets back a brand new one. The old one is consumed:
curl -s https://api.example.com/v1/auth/token/refresh \
-H 'Content-Type: application/json' \
-d '{"refresh_token":"jpSIunl4OSVEi175Fj..."}'{
"data": {
"access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1Ni...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "bmV3LXJvdGF0ZWQtdG9rZW4..."
}
}So what happens if an attacker steals a refresh token and replays one that has already been rotated away? That is the tell. A legitimate client never reuses an old refresh token because it always holds the newest one. A replay means two parties have tokens from the same family, so one of them must be a thief. Polaris treats that as exactly what it is and revokes the entire family:
{
"error": "invalid_grant",
"message": "The refresh token is invalid or expired."
}Both the real user and the attacker get logged out, the session is killed off, and an auth.refresh_reuse_detected event fires so your alerting can light up (we will discuss events in part 5). This is straight out of the OAuth 2.0 Security Best Current Practice (RFC 9700), and it is the single most valuable thing you can do with refresh tokens. It is also the kind of thing almost nobody hand-rolls correctly, which is the whole argument for using a module.
The refreshed access token re-resolves your roles from the database (and the flat scope list too, when embedding is on). If an admin stripped your permissions thirty seconds ago, your next refresh reflects that. Token claims are a convenience, not the source of truth.
Sessions: See Your Devices, Kill the Lost Ones
Because every session is a refresh-token family with its own sid, the user can see and manage them:
curl -s https://api.example.com/v1/auth/sessions \
-H 'Authorization: Bearer eyJ0eXAiOiJKV1Qi...'{
"data": {
"sessions": [
{ "id": "019ee088-...", "current": true, "ip": "203.0.113.7",
"user_agent": "Mozilla/5.0 ...", "created_at": "...", "last_used_at": "..." }
]
}
}DELETE /auth/sessions/{id} revokes one session the caller owns. Trying to revoke a session that does not exist or belongs to someone else returns 404, with no cross-user disclosure. POST /auth/logout kills the current session; POST /auth/logout-all kills every session and emits auth.sessions_revoked with the count.
Passwords: Reset and Change, Both with the Right Blast Radius
Two flows, two different behaviours:
POST /auth/password/forgottakes an email and always returns a generic 202 (anti-enumeration, again). A reset token goes out only for a known, active account, and it is stored only as a keyed HMAC.POST /auth/password/resettakes the token and a new password. On success it returns200and revokes every session: a password reset is your "I think I was compromised" button, so logging out everywhere is the appropriate blast radius.POST /auth/password/changeis for a logged-in user who knows their current password. It revokes every other session but keeps the caller's current one, because you should not log yourself out of the tab you are working in. This one is step-up gated for users with MFA, which is covered in part 3.
Recap
You now have the full identity core: enumeration-safe registration, HMAC-stored verification tokens, a generic-failure login, asymmetric JWT access tokens with a JWKS endpoint and key rotation, rotating refresh tokens that detect theft and revoke the whole family, per-device session management, and password flows with full session revocation behavior.
Next up: part 3, real MFA in an afternoon (coming soon), where that mfa_required branch in the login domain finally pays off.
Reference docs for every field and status code are at polaris.univeros.io. The framework is at univeros.io.
The Polaris series
- Meet Polaris
- Logins that do not leak (you are here)
- Real MFA in an afternoon (coming soon)
- One user, many orgs: multi-tenant RBAC (coming soon)
- Bring your own Twilio, SES, and listeners (coming soon)
Build captivating apps and sophisticated B2B platforms
Stunning solutions for web, mobile, or cross-platform applications.
Let's Talk.jpg)