Blog
DEV TIPS & TRICKS

Logins That Do Not Leak: Email Verification and Rotating JWTs with Polaris (Part 2)

Updated:
Jul 10, 2026
Jul 10, 2026
6 min read
Polaris logo
By
Antonio Ramirez Cobos
,
Co-founder & CTO at 2am.tech
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:

  • sid is 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, and auth_time describe how the user proved who they are. amr lists the methods (["pwd"] for password only, ["pwd","otp"] after MFA). auth_time is when they last did a strong auth, and it powers step-up in part 3.
  • org is null and roles is empty until the user scopes the session to an organization. That whole story is part 4.
  • There is no flat scope claim by default. The token carries roles only and Polaris resolves the real permissions server-side. If you want the permission list embedded for a cheap downstream check, flip access_token.embed_scope on and a scope array 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/forgot takes 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/reset takes the token and a new password. On success it returns 200 and 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/change is 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

Don't miss out on
our latest insights
– Subscribe Now!

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Share This Post
Back to Blog
Don't miss out on
our latest insights
– Subscribe Now!
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Navigate
Start Now