The platform is the identity provider. No service creates a separate identity path. The Security Team must review changes involving authentication, authorisation, cryptography, personal data, or a customer-facing API.
| Flow | Used for |
|---|---|
| Authorization code with PKCE | Our own browser, mobile and desktop clients, public clients that ship no secret |
| Authorization code with client secret | Third-party server-side integrations, where the partner’s backend exchanges the code |
| JWT bearer grant (RFC 7523) | Partner SSO, where a trusted partner asserts a user’s identity |
| Short grant code | Single-use handoff between our own surfaces |
| OIDC relying party | Accepting a partner identity provider; we validate their token and issue our own |
- Never ask a user for their NinjaTrader password. Delegated access is the purpose of OAuth.
- Keep client secrets only in a backend. Anything sent to a user is public.
- Register redirect URIs and match them exactly. Authorization codes are single-use and short-lived.
- Access tokens are short-lived, and refresh tokens rotate. The client handles refreshing. A resource server returns
401 invalid_tokenand nothing else. - Never log a token. Remove query strings before sending anything to an error tracker or analytics platform. Auth flows put exchangeable secrets there, and redaction cannot remove data from inside a URL. See observability for the redaction pipeline.
Our hardening follows the OAuth 2.0 Security Best Current Practice (RFC 9700). Native clients follow OAuth 2.0 for Native Apps (RFC 8252): use the system browser, PKCE, and a registered redirect URI.
Starting a flow: state, nonce and PKCE
Every authorization-code flow uses an unguessable state. Generate it for each request and store it server-side or in a signed cookie. On the callback, reject requests whose returned state does not match the stored value, then discard it. This prevents CSRF, and the comparison uses constant time.
sequenceDiagram participant U as User agent participant C as Client participant A as Auth server participant R as Resource server C->>C: generate state and PKCE verifier C->>A: authorize with code_challenge S256 and state A->>U: login and consent U->>A: approve A->>C: redirect with code and state C->>C: verify state matches C->>A: exchange code and verifier for token A->>C: access token and refresh token C->>R: request with Bearer token R->>R: validate signature, iss, aud, exp R->>C: response
Public clients use PKCE with code_challenge_method=S256. Reject plain. It provides no protection. Create a new verifier for every request and derive the challenge with SHA-256.
For OIDC relying-party flows, also send a nonce and check it in the returned ID token. This ties the token to this request and prevents token replay.
Validating a bearer token
Every service that accepts a bearer token is a resource server. It validates the token locally on each request instead of introspecting it each time. Pin the algorithm and never accept none. Require a kid that resolves to a known key and verify the signature. Match iss and aud exactly. Check exp and iat with minimal clock skew. Skipping any of these checks is a known type of vulnerability. See the OWASP JSON Web Token Cheat Sheet for the alg=none and key-confusion attacks these checks prevent.
Find JWKS through .well-known metadata, cache keys by kid, and fetch them again when kid is unknown. This lets keys rotate without redeploying resource servers. Fail closed if fetching fails. Never accept an unverified token. The web trader’s auth-service (packages/tradovate/auth-service/src/ops/jwks.ts in NT-NinjaTrader/ninja-web-trader) is the standard implementation for local validation and key caching.
Between services, do not forward a user’s token across a trust boundary. Use an OAuth 2.0 Token Exchange (RFC 8693) so the receiving service gets a token minted for its own audience. Authorise the caller for the resource instead of treating a valid token as permission.
Token lifetimes and rotation
Access tokens last minutes. Refresh tokens last longer and have both an idle cap and an absolute cap. Rotate the refresh token on every refresh. If someone presents a refresh token that was already rotated, treat it as theft. Revoke the whole token family and require authentication again, as described in the RFC 6819 replay defence. Quietly rotating the token again would hide the attack.
A resource server checks only exp, so a short access-token lifetime is how revocation reaches it. Set lifetimes so a revoked session ends quickly without checking introspection on every request.
Client-side token storage
Store tokens in the platform secure store. Never store them in application code or plain files.
- Mobile and desktop use the OS keystore, Keychain, Android Keystore, or the platform secure-storage API.
- Browser clients avoid
localStoragefor refresh tokens. Prefer a backend-for-frontend that holds the refresh token and setsHttpOnly,Secure,SameSitecookies, or hold the access token in memory only.
Sessions, logout and revocation
Logout must end the session, not only clear the client. Support OIDC RP-initiated logout and back-channel logout so a session ended at the identity provider also ends at relying parties. A resource server knows that a token is dead through its short exp; it does not run a revocation check on every request.
Require step-up authentication for sensitive actions: funding, withdrawals, or changing authentication settings. Ask for fresh authentication with max_age. Check the acr and amr claims to confirm the assurance level before allowing the action.
Service-to-service and higher-assurance controls
For service-to-service auth on GKE, connect workload identity to GCP Workload Identity instead of using long-lived service-account keys. This matches the least-privilege and secret-storage approach on the security page.
Rate-limit and monitor the token and authorization endpoints for credential stuffing and code-injection attempts. Connect this to the edge rate limiting described on the security page.
Sender-constrained tokens, DPoP (RFC 9449) or mTLS-bound tokens (RFC 8705), tie a token to its holder, so a stolen bearer token is useless. They are planned for high-value APIs. Bearer tokens with short exp are not the final limit.
Examples
These examples are for illustration, not a walkthrough. Each snippet shows the form of a check in the language that implements it.
Validating a bearer token
A resource server pins the algorithm, finds the key by kid, matches iss and aud, allows minimal clock skew, and fails closed. The examples use java-jwt (Scala/JVM), jose (TypeScript), and PyJWT (Python).
val verifier = JWT.require(Algorithm.RSA256(jwks.keyFor(kid), null)) // pin RS256, resolve by kid
.withIssuer(expectedIss)
.withAudience(myAudience)
.acceptLeeway(30) // seconds of clock skew, no more
.build()
Try(verifier.verify(rawToken)) match {
case Success(decoded) => Right(decoded)
case Failure(_) => Left(Unauthorized("invalid_token")) // fail closed
}import { createRemoteJWKSet, jwtVerify } from "jose";
const jwks = createRemoteJWKSet(new URL(jwksUri)); // caches keys, re-fetches an unknown kid
try {
const { payload } = await jwtVerify(rawToken, jwks, {
algorithms: ["RS256"], // pin the algorithm; never accept "none"
issuer: expectedIss,
audience: myAudience,
clockTolerance: 30, // seconds of clock skew, no more
});
return ok(payload);
} catch {
return unauthorized("invalid_token"); // fail closed
}import jwt
from jwt import PyJWKClient
signing_key = PyJWKClient(jwks_uri).get_signing_key_from_jwt(raw_token) # resolve by kid
try:
decoded = jwt.decode(
raw_token,
signing_key.key,
algorithms=["RS256"], # pin the algorithm; never accept "none"
issuer=expected_iss,
audience=my_audience,
leeway=30, # seconds of clock skew, no more
)
return ok(decoded)
except jwt.PyJWTError:
return unauthorized("invalid_token") # fail closedStarting a flow: state and PKCE
This is for client-side use on our browser and native surfaces. Verify state on the callback with a constant-time comparison (TypeScript/Node shown):
import { timingSafeEqual } from 'node:crypto';
// Constant-time compare with a length guard.
// timingSafeEqual throws on unequal-length buffers, so guard first.
function safeEqual(a: string, b: string): boolean {
const ab = Buffer.from(a);
const bb = Buffer.from(b);
if (ab.length !== bb.length) return false;
return timingSafeEqual(ab, bb);
}
// on /callback
const expected = req.session.oauthState; // set before redirect to /authorize
const got = String(req.query.state ?? '');
if (!expected || !safeEqual(expected, got)) {
return res.status(400).send('state_mismatch');
}
delete req.session.oauthState; // single useCreate the PKCE challenge from a new verifier with SHA-256:
import crypto from 'node:crypto';
const base64url = (b: Buffer) => b.toString('base64url');
const verifier = base64url(crypto.randomBytes(32));
const challenge = base64url(crypto.createHash('sha256').update(verifier).digest());
const authUrl = `${authorize}?response_type=code&client_id=${clientId}`
+ `&redirect_uri=${encodeURIComponent(redirectUri)}`
+ `&code_challenge=${challenge}&code_challenge_method=S256` // never 'plain'
+ `&state=${state}`;See also
- Observability — the redaction pipeline that removes tokens from logs and query strings.
- Security — least privilege, secret storage, edge rate limiting, and Workload Identity.
- Open questions — token lifetimes, browser refresh-token storage, and sender-constrained tokens.