Hidden Authentication Path

ID

hidden_authentication_path

Severity

critical (identity from request input) / high (non-standard comparison, conditional bypass)

Remediation Complexity

medium

Remediation Risk

high

Remediation Effort

medium

Family

API2:2023 - Broken Authentication

CWE

CWE-288, CWE-306, CWE-1391

Resource

source

Language

Java, Kotlin, C#, Python, JS/TS, PHP, Go, Swift, Ruby

Description

Every application has a canonical way of establishing identity — a login handler, a token filter, a framework authentication API, an identity-provider callback. It is usually one or two places. A back door is a third place: code that arrives at an authenticated principal without going through the canonical mechanism.

This detector censuses every site that binds the current request to an identity — an assignment into the framework’s security context or principal holder, session creation or a session attribute carrying identity, an issued authentication token, a granted role or authority — and classifies each by the verification that dominates it on the path in:

Class What precedes the identity assignment Verdict

Canonical

A recognised authentication primitive: a password verified against a stored hash, a token or signature validated, a framework login API, an identity-provider callback. Also the credential the request sent compared with the one the granted record itself stores (runner.TokenHash), directly or through a helper of the application that returns that equality

Not a finding. These sites are the canon

Non-standard comparison

A direct equality or string comparison of a request value against a secret from any source — literal, configuration, environment, data store, or another service

Finding at high. The operand’s provenance is recorded and never gates the finding

Unverified

Nothing. The identity is derived from a request-controlled value — header, parameter, cookie, body field — and assigned

Finding at critical, the highest of the classes: there is no secret to guess

Conditional bypass

A flag, mode, profile or environment check whose true branch reaches the grant without the canonical path

Finding at high. Includes the shape where an unset variable makes the branch the default

Every finding names what was granted — a principal, a session, a token, a role — and what verification preceded it, next to the canonical sites it was compared against.

Rationale

The finding is that identity gets established in a way this application does not otherwise establish it. It is a statement about the shape of the login and identity-binding behaviour, not about where a credential is stored.

A hardcoded credential is neither necessary nor sufficient. Not necessary: the magic value can be read from the data store, from configuration, from an environment variable or from a header, and the back door is exactly as open — this is the common case in real code and it is invisible to any rule that looks for literals. Not sufficient: a literal in a comparison that grants nothing is a lint, not a vulnerability. Anchoring this rule on literals would make it a second-rate secrets scanner and would miss the cases that matter.

That is also why this detector never consults the credential recognisers as a firing condition. It answers a different question from the hardcoded-credential rules: they ask "is there a secret in the source?", this one asks "is identity granted here without going through the front door?". Both can fire on one line and both be right, with different remediations — remove the secret, versus remove the path. A credential literal at the site is added as evidence and never raises severity.

The density rule, and why this detector often reports nothing

The census is only meaningful where a canon exists. If no site in an application classifies as canonical, the analysis has not recognised that application’s authentication mechanism, and every site it has would be reported — which is noise, not a finding. The detector reports nothing there, and records the decision as an informational finding naming how many sites were withheld and why (reportDensitySuppression, on by default). A decision that lives only in a debug log is the one direction that silently costs real findings.

The canon is counted per application: per discovered service, falling back to the repository for a file no service claims. In a monorepo serving three applications, pooling the count would let the first application’s login handler establish a canon that silences the third application’s back door.

Findings are therefore few and high-severity by construction. A large volume on a real repository means the canonical classification is under-recognising, not that the application is full of back doors — see Configuration for how to teach it the missing login primitive.

Vulnerable examples

A comparison against a secret read from the data store. No literal appears anywhere in the file, and success assigns a principal:

public String elevate(HttpServletRequest request) {
  String presented = request.getHeader("X-Admin-Token");
  String expected = adminTokenRepository.findActiveToken();   // provenance: data store
  if (presented.equals(expected)) {                           // non-standard comparison
    SecurityContextHolder.getContext().setAuthentication(admin);
  }
  return "admin";
}

An identity taken from a request header and trusted, with no verification at all — reported at critical, because the caller states who they are and is believed:

public void filter(HttpServletRequest request) {
  String caller = request.getHeader("X-Remote-User");
  SecurityContextHolder.getContext()
      .setAuthentication(new UsernamePasswordAuthenticationToken(caller, null));
}

A conditional bypass in the form that matters most: the branch is taken in every deployment where nobody set the variable, which is the state of a fresh environment:

if (!"strict".equals(System.getenv("AUTH_MODE"))) {
  SecurityContextHolder.getContext().setAuthentication(operator);
}

A service behind a gateway, believing a header the gateway is supposed to set. Two things here are invisible to a catalog of framework calls: the sink is the project’s own helper rather than Spring’s, and the principal is not built where it is bound — it comes back from a private method. Both are followed:

protected void doFilterInternal(HttpServletRequest request, ...) {
  LoginUser loginUser = buildLoginUserByHeader(request);      // (1) the value's declaration
  if (loginUser != null) {
    SecurityFrameworkUtils.setLoginUser(loginUser, request);  // a grant no catalog lists
  }
  chain.doFilter(request, response);
}

private LoginUser buildLoginUserByHeader(HttpServletRequest request) {
  String text = request.getHeader(LOGIN_USER_HEADER);         // (3) ... to the read
  return JsonUtils.parseObject(text, LoginUser.class);        // (2) what the helper returns ...
}

The same act with no sink at all. Nothing here is written into a holder, nothing carries a name any catalog could hold — the value is simply produced, and from that point on it is who the request is:

protected void doFilterInternal(HttpServletRequest request, ...) {
  User acting = customerService.getByEmail(request.getParameter("act_as"));   // no grant, no holder
  ...
}

This one is reported only when the application’s convention profile is available, because what makes it recognisable is the type: the profile says this application carries its caller in a User, and a User built from request input inside a unit that runs on every request is an identity being established, whatever the service that built it is called. Every repository names that service differently, which is exactly why no catalog can ever carry it. Both halves of that are load-bearing: with no profile this shape is not reported at all, and outside an interception unit the same code is an object-level authorization question — a different rule, and a false positive here.

Fixed examples

Route the grant through the primitive the application’s own canonical sites use:

public String login(String username, String password) {
  Authentication result =
      authenticationManager.authenticate(new UsernamePasswordAuthenticationToken(username, password));
  SecurityContextHolder.getContext().setAuthentication(result);   // canonical: not a finding
  return "home";
}

Where identity arrived in a header, verify a credential and derive the identity from the verified subject rather than from the caller’s claim:

public void filter(HttpServletRequest request) {
  Jwt token = jwtDecoder.decode(request.getHeader("Authorization"));   // token validation
  SecurityContextHolder.getContext().setAuthentication(converter.convert(token));
}

Delete the bypass branch. A branch that skips authentication in one environment is present in the binary shipped to every environment.

Remediation

Remove the path; do not merely rotate the value it compares against. Rotating a compared secret leaves the second door in place with a new key.

  • Route the grant through the application’s own authentication primitive — the same call the canonical sites named in the finding use. If the caller cannot reach it, the requirement is a new authentication mechanism, not a second comparison.

  • A request header, parameter, cookie or body field is caller-controlled input and can never establish identity. Verify a credential, then derive the identity from the verified subject.

  • Where the grant is reachable through a flag, mode or profile check, delete the branch — and note that an unset variable makes it the default.

Configuration

The per-framework shapes are configuration, not code: a framework this detector does not yet know is added by editing the catalog, without touching the rule’s logic. The catalog lives under properties.identityShapes in hidden_authentication_path.yml, keyed by language, and each language carries the same keys:

Key What it lists

principal, session, token, role

The grants. A session grant additionally requires an identity-bearing key, so session['locale'] = x is not counted as a login

sessionStores

Expression heads that denote a session or cookie store, for the languages where the grant is a subscript assignment ($_SESSION, req.session, call.sessions)

requestInput

Reads of caller-controlled input — a header, a parameter, a cookie, a body field

environment, configuration, dataStore, service

Where a compared value came from. Reported as evidence; also what lets an operand named row be recognised as a secret at all, which is the data-store case this rule exists to catch

passwordVerification, tokenValidation, frameworkLogin, identityProvider

The front doors. A grant these dominate is the canon, and is never reported

Entries take either form, as in the mediatorMethods allowlist of the authorization detectors:

identityShapes:
  java:
    frameworkLogin:
      - authenticate                              # bare name: matches any receiver
      - "AuthenticationManager#authenticate"      # qualified: the receiver must name that type

Prefer the qualified form, and note that which list an over-broad entry lands in decides what it costs. On an authentication-primitive list a coincidence makes a site canonical and withdraws a true finding, silently; on a grant list it merely adds a site that is then classified on its own evidence. That asymmetry is why PasswordEncoder#matches is qualified while checkpw is not: a bare matches would make every Pattern.matches call look like a login.

Three language-independent vocabularies sit under properties.identityHints: identityKeys (session and cookie names that carry an identity), secretOperands (names that read as a secret), bypassFlags (flags and modes whose branch can skip the canonical path), plus dataStoreNames and serviceNames. They are matched per identifier token — not as substrings — so device does not answer for dev; conversely key is deliberately absent, because keySet splits into key and set and every map lookup would read as a secret.

Three more vocabularies describe the grant a catalog cannot enumerate — the helper a team writes over its framework, whose name is that team’s and no one else’s. A call is read as a grant when its name begins with an identityBindVerbs verb and it carries both an identitySubjects word — names that say who, which is a shorter list than identityKeys and deliberately so — and an identityHolders word, on the name or on the receiver: SecurityFrameworkUtils.setLoginUser(…​) does, and comment.setUser(name) — a setter on a domain object, in every code base there is — does not, because nothing in it says the thing bound into is the request’s identity holder. Names under identitySwitchNames (impersonate, loginAs, switchUser, loginUsingId) need nothing else: no domain operation is called any of these. identityHolders doubles as the content marker that decides which files are parsed at all, so keep its words specific — each one makes every file containing it worth opening.

The application’s own profile

Both settings live in xygeni.ai.yml, amended in custom.ai.yml — not in this detector’s own configuration, and not in xygeni.apisecurity.yml: the profile is computed once for the scan and shared by every consumer of it, and its consumers sit in both engines.

aiMode (default off, also --ai-mode) says what a run is willing to spend. auto uses the profile kept for the branch and keeps it up to date against what changed, establishing one only when there is none, so the first scan of a branch pays and the ones after it do not. stored uses what is kept and never calls a model, and never writes — the mode for an air-gapped run, and the way to leave a profile file you have edited exactly as you left it. rebuild establishes one from scratch and keeps it over the top. off profiles nothing.

None of them calls a model by itself: one is called only where an AI agent is configured for the scan and the repository has something worth profiling. A profile is also only asked for where an enabled detector of the scan reads one, so turning this detector off — by policy, or with --detectors — is on its own enough to make the scan call no model for a profile.

aiContextFile (also --ai-context-file) names a file to keep the profile in, relative to the scanned directory. It is unset by default, and then the profile is kept on the Xygeni platform, per branch. Naming one makes that file the store in place of the platform, read and written exactly as the mode above says — which is how a profile written by hand is fed in, and how one scan is reproduced exactly.

A profile you write yourself is used exactly as you wrote it, in every mode. A profile a scan generated and you then edited is still a generated profile: under auto or rebuild it can be brought up to date and your edits written over. stored is the one mode that never writes, and is how an edited file stays as you left it. The scan says in its log whenever it replaces the file.

Either way the profile is the typed facts an application was found to hold, each with file:line evidence. Two of them reach this detector.

An identity binding names the symbol this application binds a caller with — the helper it wrote over its framework. That makes a third tier of grant recognition, in decreasing certainty: the catalog answers for the framework, the profile answers for this repository by resolved symbol, and the name shape answers when there is no profile, by what the name looks like.

An identity source additionally carries the type the caller is held in, and that is the one thing no catalog can substitute for — see the last vulnerable example above.

Three properties hold whatever the profile says, and they are what make it safe to consume:

  • Add-only. There is no field in a profile that can disable, downgrade or suppress anything. The worst a wrong fact can do is add vocabulary that matches nothing, which is why a comment in a scanned repository cannot be an off switch.

  • Facts are verified against the tree. A fact whose cited line is not there is dropped when the fact is established, deterministically and for free, and the count of dropped facts is recorded; a fact is checked again whenever the file it cites changes. A profile written from scratch by hand and fed in with aiContextFile is used as it was written.

  • Absent, unreadable or invalid, the census behaves exactly as it does with no profile at all.

reportDensitySuppression (default true) switches the informational finding that records a density-rule suppression. Set it to false in a repository where that volume is unhelpful.

Test code follows the scan’s own include/exclude filter: a file the user excluded is never opened.

References

© 2026 Xygeni. All rights reserved