Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.aspfox.com/llms.txt

Use this file to discover all available pages before exploring further.

JWT structure

Every AspFox JWT contains these claims:
ClaimTypeValue
substring (GUID)User ID
emailstringUser email address
namestringDisplay name
tenant_idstring (GUID)Currently active tenant ID
rolestringUser’s role in the active tenant (Owner, Admin, Member, or custom role name)
permissionsstring[]Array of permission strings for this role
is_adminbooleantrue if user has global admin access (Hangfire dashboard, admin panel)
jtistring (GUID)Unique token ID, used for token family tracking
impersonator_idstring (GUID)Present only during admin impersonation; contains the real admin’s user ID
Access tokens expire after 15 minutes. Refresh tokens expire after 7 days.

Registration and email verification

New users cannot log in until they verify their email address.
POST /api/v1/auth/register
  { "email": "user@example.com", "password": "…", "name": "…" }


RegisterCommandHandler
    │  creates User (IsEmailVerified = false)
    │  sends email with verification link
    │  does NOT issue tokens


201 Created (no tokens in response)


User clicks link in email
  GET /api/v1/auth/verify-email?token=<url-safe-base64-token>


VerifyEmailCommandHandler
    │  validates token (not expired, belongs to this user)
    │  sets IsEmailVerified = true
    │  deletes the verification token


200 OK with token pair (access + refresh)
User is now logged in.
The verification token is URL-safe base64 encoded — no +, =, or / characters appear in the URL. Tokens expire after 24 hours. If expired, the user can request a new one via POST /api/v1/auth/resend-verification. If an unverified user tries to log in, the response is 400 Bad Request with error code EMAIL_NOT_VERIFIED, not 401. This distinction lets the frontend show an appropriate message with a resend link.

Refresh token rotation with reuse detection

Every time an access token is refreshed, the old refresh token is revoked and a new one is issued. This is called token rotation.
POST /api/v1/auth/refresh  { "refreshToken": "<token>" }


RefreshTokenCommandHandler
    │  looks up the token in the database

    ├─ Token found AND not revoked:
    │      revoke the old token
    │      issue new access token + new refresh token
    │      return 200 OK with new token pair

    ├─ Token found AND already revoked:
    │      REUSE DETECTED
    │      revoke ALL tokens in the same family (same FamilyId)
    │      return 401 with code TOKEN_REUSE_DETECTED

    └─ Token not found OR expired:
           return 401 with code TOKEN_EXPIRED or INVALID_TOKEN
Why reuse detection matters: If an attacker steals a refresh token and uses it before the legitimate user does, the legitimate user’s next refresh attempt will fail — their token was already revoked when the attacker refreshed. At that point, the entire token family is revoked, forcing both the attacker and the legitimate user to re-authenticate. This detects the theft and limits the window of compromise. Token families use the FamilyId field on RefreshToken. Every refresh creates a new token with the same FamilyId. When reuse is detected, a single UPDATE refresh_tokens SET IsRevoked = true WHERE family_id = @familyId revokes the entire chain.

Token storage in the frontend

TokenStorageLifetime
Access tokenZustand store (in-memory)Lost on page refresh; re-issued automatically
Refresh tokenlocalStorage7 days
On page load, the Zustand auth store initializer checks localStorage for a stored refresh token. If found and not expired, it calls POST /api/v1/auth/refresh silently to get a fresh access token. If the refresh fails (revoked, expired), the user is shown the login page. The Axios interceptor handles concurrent 401 responses during token refresh. If three API calls fire simultaneously and all return 401, only one refresh request is sent. The other two are queued and retried with the new access token once it arrives.
localStorage is accessible to JavaScript on the same origin. This is a known tradeoff for developer-facing boilerplate. If your application has stricter security requirements, you can modify the auth flow to use httpOnly cookies for the refresh token. The backend already sets cookies in the response — see AuthController.SetRefreshTokenCookie().

Social login — Google and GitHub OAuth

User clicks "Sign in with Google"


GET /api/v1/auth/google
    │  redirects to Google's authorization URL


User authenticates with Google


GET /api/v1/auth/google/callback?code=…&state=…


GoogleCallbackHandler
    │  exchanges code for Google token
    │  fetches Google user profile (email, name, picture)

    ├─ User with this email exists:
    │      link Google provider if not already linked
    │      issue token pair for existing user

    └─ No user with this email:
           create new User (IsEmailVerified = true — email verified by Google)
           issue token pair


Redirect to frontend with tokens as URL fragment
Frontend stores tokens and redirects to dashboard.
GitHub OAuth follows the same pattern via /api/v1/auth/github and /api/v1/auth/github/callback.
POST /api/v1/auth/magic-link/request  { "email": "user@example.com" }


Always returns 200 OK (regardless of whether email exists)
If user exists: sends magic link email (10-minute expiry)


User clicks link in email
GET /api/v1/auth/magic-link/verify?token=<token>


MagicLinkVerifyHandler
    │  validates token (not expired, not used)
    │  marks token as used
    │  if user email not verified: marks as verified
    │  issues token pair


Redirect to frontend with tokens
Magic links expire after 10 minutes. They are single-use. If a user clicks an expired link, they see a clear message and can request a new one.

Admin impersonation

Admins can impersonate any user from the admin panel.
POST /api/v1/admin/users/{userId}/impersonate
    │  (requires is_admin JWT claim)


Issues a new JWT for the target user
    │  with impersonator_id = admin's user ID
    │  with all of the target user's normal claims


Frontend detects impersonator_id claim
    │  shows persistent red banner:
    │  "Impersonating {user name} — Stop impersonating"


Admin clicks "Stop impersonating"
    │  frontend discards the impersonation token pair
    │  restores the original admin's token pair from memory
    │  red banner disappears
Every impersonation event is written to the audit log with the impersonator’s ID, the target user’s ID, and a timestamp. The impersonation token has the same 15-minute expiry as a normal access token.