- Published on
A Session Can Have Only One Source of Truth: Why External Login Must Not Store a Token on the Device
- Authors

- Name
- Jack Qin
When integrating an external identity provider (Microsoft Entra, and later Apple / Google / SAML), the thing most likely to go wrong isn't "how do I log in" — the libraries handle that part for you. What actually goes wrong is a more hidden decision: what, ultimately, is the session authoritative on. Get that decision wrong and the error won't surface at login; it surfaces in a security scenario like "the admin disabled a user, and they can still use the app."
The core of this piece is one sentence: the Identity Cookie issued by the backend is the only source of truth for the session. It sounds like an arbitrary rule, but behind it is a very clear ledger — the moment you store an extra token on the device as a parallel session, you don't gain a convenient extra path, you tear open three channels at once: revocation, cold start, and audit. Once you understand those three cracks, the rule stops being "the team requires it" and becomes "not doing this guarantees a security hole."
A tempting mistake: treating the external token as a parallel session
The moment the mobile app starts integrating third-party login, a seemingly natural move appears: store the provider-issued id_token / access_token / refresh_token on the device (SecureStore or AsyncStorage) as a parallel session mechanism. "We've got the tokens anyway, might as well keep them for later."
The problem is that the backend's entire auth mechanism is designed around a single session source of truth. Storing an extra token wires a second mechanism in parallel next to that one, and the second one bypasses all of the first one's protections. Concretely, it tears into three:
- The revocation channel splits: the backend "kicks users" via
SecurityStamprotation — after a password change or a user is disabled, all clients drop on their next request. But if a parallel token exists, the admin's "disable user" only acts on the cookie channel; the path that contacts the IdP directly with the token keeps working. This is exactly where revocation springs a leak. - The cold-start path splits:
GET /users/meis meant to be the authoritative session check, run on every cold start. A parallel token means a second cold-start path, carrying its own independent, backend-unverified failure modes. - The audit surface splits: the backend's existing auth-event logging covers every login. Landing an external token in SecureStore creates a second credential store, which needs its own audit and leak detection — and usually nobody builds it.
The three cracks point to the same root cause: the session now has two sources of truth, and the second is governed by nothing the first one controls. So what the convention does is nail down "what the session is authoritative on" to exactly one.
The backend cookie is the mobile app's only auth source of truth
What to do: every mobile auth flow — password, Microsoft Entra, future Apple Sign-In — must end with the backend issuing a standard IdentityConstants.ApplicationScheme cookie (app-auth) via SignInManager.SignInAsync. This cookie is the only source of truth for "is the user logged in." The mobile app never persists an external provider token as a parallel session mechanism.
How to apply it:
- A new mobile auth flow POSTs to a backend endpoint, and the backend issues the cookie via
SignInManager.SignInAsync. The mobile client doesn't decode the external token, doesn't store it, doesn't refresh it on the device. - The mobile SecureStore may hold display-only hints (
auth.userId,auth.displayName) for fast cold-start rendering, but never a token. These hints must be re-validated byme()on every cold start — they are a rendering accelerator, not a basis for the session. - The external provider library (e.g.
expo-auth-session) hands theid_tokenstraight to the backend's exchange endpoint, and discards it from memory once the POST completes.
Cookie expiry is controlled by the server with sliding renewal (14 days). The platform-native cookie jar (the mobile app uses react-native-nitro-cookies) handles persistence and re-attachment, without application code intervening.
const loginWithMicrosoft = useCallback(async (): Promise<void> => {
clearAuthenticatedQueryDataBeforeLogin(queryClient)
const { idToken } = await signInWithMicrosoft() // PKCE → id_token in memory
await authClient.loginWithMicrosoft(idToken) // POST → backend sets the cookie
// idToken leaves scope here; the cookie is the persistent session
const user = await queryClient.fetchQuery({ ...currentUserQueryOptions, staleTime: 0 })
await saveSession({ userId: user.userId, displayName: user.displayName })
}, [queryClient])
Strictly forbidden — all three plug the three cracks above:
- Storing
id_token/access_token/refresh_tokenin SecureStore or AsyncStorage - Providing an accessor like
useAuth().getAccessToken(), or any code path that expects the client to hold a token - A second cold-start path that reads a stored token and contacts the IdP directly (bypassing
SecurityStamprevocation)
The backend implementation splits into three reusable contracts
The backend uses ASP.NET Core Identity + cookie authentication as the only session model. External providers all terminate in the same IdentityConstants.ApplicationScheme cookie. The implementation splits into three pieces, each corresponding to a temptation that recurs.
1. The find-or-create helper for external login
Problem: every external provider does the same dance — look up the user by email → auto-create if absent → link idempotently via AddLoginAsync → call SignInManager.SignInAsync. Copying this into each endpoint invites provider-specific drift (inconsistent default Role, inconsistent EmailConfirmed default, a missed security-stamp rotation) — and that kind of drift is a breeding ground for security bugs.
Solution: one helper per Identity module, taking a normalized principal and returning a typed Result. Place it in Modules/Identity/Application/, registered as Scoped (matching the UserManager / SignInManager lifetime).
public sealed record MicrosoftPrincipal(string Email, string ProviderKey, string? DisplayName);
public sealed class MicrosoftAccountLinkingResult
{
public User? User { get; }
public MicrosoftAccountLinkingErrorCode? ErrorCode { get; }
public static MicrosoftAccountLinkingResult Success(User user) => new(user, null);
public static MicrosoftAccountLinkingResult Failure(MicrosoftAccountLinkingErrorCode code) => new(null, code);
}
public enum MicrosoftAccountLinkingErrorCode { AccountDeactivated, UserCreateFailed }
Auto-create defaults (project convention): IsActive=true, Role="user" (a field on the User entity, not AspNetRoles), EmailConfirmed=true, FullName=<displayName claim>, password set to a long random unusable hash (killing the password-reset-hijack path), and no site/group permissions (assigned later by an admin). The last two are especially security-motivated: the unusable password blocks the "take over an external account via password reset" path, and zero default permissions ensure a new account can see nothing until an admin explicitly grants access.
Reuse note: a future Apple Sign-In will define an AppleAccountLinking of the same shape. Don't generalize into "one helper with a provider string" before you've assembled three providers — provider-specific quirks (claim names, oid vs sub, tenant checks) make premature generalization expensive. This is a reverse boundary of DRY: with only one or two instances and the differences between them still unknown, duplication is cheaper than the wrong abstraction.
2. JWT validation for the native id_token exchange
Problem: mobile/native clients can't go through the server-side OIDC redirect flow (iOS ASWebAuthenticationSession's cookie store is isolated from URLSession — during the auth session, the backend's Set-Cookie never reaches the app's fetch jar). So the pattern can only be: the mobile client does PKCE to obtain an id_token, POSTs it to a backend endpoint, and the backend validates the JWT and issues the project-standard Identity Cookie.
Since the backend issues a session on the strength of a token the client handed over, validating that token becomes the root of the entire trust chain. It must pass this hardening checklist — every line rejects a class of forgery:
| Setting | Required value | Why |
|---|---|---|
ValidateIssuer | true, exact match | Reject tokens from other tenants/providers |
ValidateAudience | true, exact match against Entra:MobileClientId | Reject tokens issued for a different app |
ValidateLifetime | true | Reject expired / not-yet-valid tokens |
ClockSkew | TimeSpan.FromMinutes(5) | Microsoft / OAuth WG recommended default |
RequireSignedTokens | true | Reject unsigned / none-algorithm tokens |
ValidAlgorithms | ["RS256"] (or an explicit allow-list) | Defends against the HS256 algorithm-confusion attack — without it, an attacker can sign a token using the JWKS public key as a symmetric key, and the validator will let it through |
IssuerSigningKeys | From ConfigurationManager<OpenIdConnectConfiguration> (singleton, auto-refresh) | JWKS rotation is handled automatically; don't hand-roll JWKS fetching |
Explicit tid claim check (after validation) | Compared against the configured tenant id | Defense in depth — should the authority URL accidentally allow multi-tenant, the tid check still rejects cross-tenant tokens |
The line most worth covering on its own is ValidAlgorithms, because what it defends against is a counterintuitive attack. The public keys in JWKS are public. If the validator doesn't lock the algorithm, an attacker can take that public key and use it as the symmetric key for HMAC, signing a token with HS256 — and a validator that accepts both RS256 and HS256 will verify that HS256 signature with the same public key, and pass it. Locking ValidAlgorithms = ["RS256"] cuts off this confusion path. Miss it, and the door is wide open.
The validator must be registered as a singleton, so the ConfigurationManager's JWKS cache is shared across requests (the cache + auto-rotation is the expensive part; creating a new one per request throws that caching away).
Testing contract: a valid token round-trips; expired / wrong audience / wrong issuer → distinct error codes; the HS256 algorithm-confusion attack (signing with the JWKS public key as a symmetric key) → InvalidToken; tid mismatch → TenantMismatch; JWKS unreachable (network failure) → MetadataUnavailable (no crash). That HS256 test is the gatekeeper for that one line of config — the day someone deletes ValidAlgorithms, it goes red immediately.
3. Per-environment conditional endpoint registration
Problem: external-provider endpoints depend on per-environment config (Entra:TenantId, Entra:MobileClientId, etc.). One lazy approach is to register them regardless and return 503 from inside the handler. But that inflates the API surface, complicates client detection, and creates the awkward state of "feature disabled but still receiving requests" — an endpoint that takes requests only to reject them is pure dead weight.
Solution: conditional registration. When required config is missing, the route simply doesn't exist (404), rather than existing and returning 503. The frontend discovers which providers are enabled via a separate providers endpoint and gates the UI accordingly.
var tenantId = config["Entra:TenantId"];
if (!string.IsNullOrEmpty(tenantId))
{
group.MapGet("/login/microsoft", ...);
group.MapGet("/callback/microsoft", ...);
// mobile endpoint additionally requires MobileClientId
if (!string.IsNullOrEmpty(config["Entra:MobileClientId"]))
group.MapPost("/microsoft/mobile", ...);
}
// discoverability endpoint — always present, returns the enabled set
group.MapGet("/providers", (IConfiguration cfg) =>
{
var providers = new List<string> { "credentials" };
if (!string.IsNullOrEmpty(cfg["Entra:TenantId"])) providers.Add("microsoft");
return Results.Ok(new { providers });
});
This design brings, as a free byproduct, a zero-release rollback path: to disable mobile Microsoft login across the entire device fleet, just unset Entra:MobileClientId on the backend — the endpoints stop mapping, the providers list drops microsoft, and the mobile client hides the button on the next cold start. No re-release needed. The frontend's providers query falls back to ["credentials"] on network failure, so the password form is always available. Binding "does the feature exist" to config, rather than to a code branch or a client version, rolls back far faster.
Counter-examples
// Counter-example: storing the external token on the device as a parallel session
const { idToken, accessToken, refreshToken } = await signInWithMicrosoft()
await SecureStore.setItemAsync('id_token', idToken) // ❌
await SecureStore.setItemAsync('access_token', accessToken) // ❌
// later, somewhere uses it to contact the IdP directly, bypassing SecurityStamp revocation — a security black hole
// Counter-example: JWT validation without an algorithm lock — wide open to the HS256 confusion attack
var parameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
// ❌ missing ValidAlgorithms = ["RS256"] and RequireSignedTokens = true
};
// Counter-example: registering the endpoint even when the feature is disabled, returning 503 internally
group.MapPost("/microsoft/mobile", (IConfiguration cfg) =>
{
if (string.IsNullOrEmpty(cfg["Entra:MobileClientId"]))
return Results.StatusCode(503); // ❌ "disabled but still receiving requests"
// ...
});
// Correct: when config is missing, don't map the route at all (404)
Putting it into practice
- The cookie is the only source of truth: any new auth flow's final step must be the backend issuing a cookie via
SignInManager.SignInAsync; the client keeps no token. - Use the
id_tokenand burn it: discard it immediately after handing it to the backend exchange endpoint; SecureStore holds only display-onlyuserId/displayName, re-validated byme()on every cold start. - Validate the JWT line by line against the checklist: especially don't miss
ValidAlgorithmsandRequireSignedTokens— the HS256 confusion attack aims straight at that gap. Register the validator as a singleton. - Extract one find-or-create, don't generalize too early: one
*AccountLinkingper provider; talk generalization only after three. - Register endpoints conditionally by config: missing config means 404, not 503; wire a providers endpoint so the frontend gates the UI on demand, and get "change config to roll back" for free.
- Clear the cache both ways at the auth boundary: clear the React Query cache on both login and logout/401 (see the frontend state-management piece).
The transferable layer
Strip away the specific APIs of OIDC and ASP.NET Core, and the genuinely transferable insight of this contract is: a session's security equals the security of its weakest source of truth. You can make the cookie channel's revocation, audit, and expiry watertight, but as long as a second token is wired in parallel on the device, the whole system's revocation latency is dictated by that ungoverned channel.
When designing any auth system, instead of hardening each path one by one, first ask: how many sources answer "is the user logged in" in this system? If more than one, are they all bound by the same revocation and audit? Converging the session onto a single source of truth is the common foundation for this entire class of security design.