Skip to main content

How it works

Authentication uses AWS Cognito as the identity provider. The iOS app communicates directly with Cognito for signup and signin, then uses the resulting JWT tokens for all API calls.

Signup

Standard email/password signup through Cognito. The user receives a verification code via email and must confirm before the account is active. No custom backend logic is involved in signup — it’s entirely Cognito-managed.

Signin

The app calls Cognito’s InitiateAuth with email and password. On success, Cognito returns:
  • Access token (JWT, short-lived) — used for API authorization
  • Refresh token (long-lived) — used to get new access tokens without re-entering credentials
Tokens are stored in the iOS Keychain.

JWT validation

The backend auth middleware validates every request:
1

Extract token

Pulls the Bearer token from the Authorization header.
2

Verify signature

Validates the JWT against Cognito’s JWKS (JSON Web Key Set) public keys. The keys are cached locally to avoid hitting Cognito on every request.
3

Check expiry

Rejects expired tokens with 401.
4

Extract claims

Pulls user ID, email, and group memberships from the token claims.

Admin access

Admin endpoints check the cognito:groups claim in the JWT. A user must belong to either the admin or testing group in Cognito to access admin routes.
Group membership is managed directly in the AWS Cognito console or via AWS CLI. There is no self-service admin promotion.

API Key access

For automation and admin tooling, the backend also supports an X-API-Key header. This bypasses Cognito entirely — the key is validated against a stored set of keys. Use this for server-to-server calls or CI/CD pipelines.
API keys grant full access and bypass user-level authorization. Treat them like root credentials. They should never be embedded in client apps.

Dev mode

When running locally with auth disabled, the middleware skips all validation and returns hardcoded dev users. This is controlled by environment configuration and should never be active in production.

Failure modes

ScenarioBehavior
Expired access token401 returned, iOS auto-refreshes via refresh token
Expired refresh token401 returned, user must re-signin
Cognito JWKS unreachableMiddleware uses cached keys; if cache is cold, all requests fail with 500
Invalid API key401 returned

Areas of improvement

API key comparison is not constant-time. In adminAuth.ts line 66, the API key is compared with providedKey === ADMIN_API_KEY (strict equality). This is vulnerable to timing attacks — an attacker can probe one character at a time by measuring response latency. Use crypto.timingSafeEqual (with a length pre-check) for the comparison.
Dev mode bypasses auth based on NODE_ENV alone. In auth.ts lines 77-81, when NODE_ENV === 'development', the middleware returns a hardcoded dev-user and skips all JWT validation. The same pattern exists in adminAuth.ts line 77-80 with a hardcoded dev-admin. If NODE_ENV is accidentally set to development in production (e.g., a misconfigured deploy), all endpoints become unauthenticated. Consider requiring an explicit DISABLE_AUTH=true flag separate from NODE_ENV.
requireAdmin does not accept the testing group, but requireAuth does. In auth.ts line 104, isAdmin is set to true for users in either admin or testing groups. However, requireAdmin in adminAuth.ts line 99 only checks for groups.includes('admin') — it rejects testing group members with 403. This inconsistency means a user can appear as isAdmin: true in req.user (via requireAuth) but be denied access to admin routes (via requireAdmin). Either both should accept testing or neither should.
Cognito JWKS cold-cache failure is unhandled. The aws-jwt-verify library caches JWKS keys internally, but on a cold start (fresh deploy, new instance), the first request triggers a network call to Cognito’s JWKS endpoint. If that call fails (e.g., transient network issue), every request returns 500 until the next attempt succeeds. There is no pre-warming of the JWKS cache at server startup.
iOS getAccessToken() does not explicitly handle token refresh. In CognitoAuthService.swift getAccessToken(), the method calls Amplify.Auth.fetchAuthSession() which Amplify internally handles refresh for. However, if the refresh token itself has expired (e.g., user inactive for 30+ days with default Cognito settings), this throws an error that bubbles up as a generic AuthError. The iOS app has no dedicated handling to detect “refresh token expired” and redirect to the sign-in screen — the user sees a cryptic error instead.
Duplicate CognitoJwtVerifier instances. Both auth.ts and adminAuth.ts create their own lazy-initialized CognitoJwtVerifier with identical configuration (same userPoolId, clientId, tokenUse). This means two separate JWKS caches and two separate HTTP connections to Cognito’s JWKS endpoint. Extract the verifier into a shared module to deduplicate.
pendingPassword stored in memory during sign-up flow. In CognitoAuthService.swift, the user’s plaintext password is held in pendingPassword so it can be used for auto-sign-in after confirmation. It is cleared on signOut() and resetState(), but if the user abandons the verification flow without completing it or signing out, the password remains in memory for the lifetime of the CognitoAuthService instance.