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 ( |
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 |
Unverified |
Nothing. The identity is derived from a request-controlled value — header, parameter, cookie, body field — and assigned |
Finding at |
Conditional bypass |
A flag, mode, profile or environment check whose true branch reaches the grant without the canonical path |
Finding at |
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 |
|---|---|
|
The grants. A |
|
Expression heads that denote a session or cookie store, for the languages where the grant is a
subscript assignment ( |
|
Reads of caller-controlled input — a header, a parameter, a cookie, a body field |
|
Where a compared value came from. Reported as evidence; also what lets an operand named |
|
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
aiContextFileis 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
-
OWASP API Security Top 10 (2023) - API2:2023 - Broken Authentication.
-
CWE-288 : Authentication Bypass Using an Alternate Path or Channel.
-
CWE-306 : Missing Authentication for Critical Function.
-
CWE-1391 : Use of Weak Credentials.
-
OWASP API2:2023 : Broken Authentication.