split(sep, limit) throws away the rest of the string
published
TL;DR
JavaScript’s String.prototype.split(separator, limit) stops once it has limit pieces and drops whatever is left. 'ana:pa:ss'.split(':', 2) is ['ana', 'pa']. Python’s maxsplit and Go’s strings.SplitN keep the remainder in the last element, so code ported from them, or written from memory of them, silently loses data. To split on the first separator only, use indexOf and slice.
The problem
HTTP Basic authentication sends user:password in base64. The obvious decoder splits on the colon:
const header = 'Basic ' + btoa('ana:pa:ss')
const [user, pass] = atob(header.slice(6)).split(':')
console.log(JSON.stringify([user, pass]))
["ana","pa"]
The password is pa:ss, and the code compares pa against the stored hash. The user gets “wrong password” forever, and nothing in the logs says why.
The tempting fix is to pass a limit, on the assumption that it works like Python’s:
console.log(JSON.stringify('ana:pa:ss'.split(':', 2)))
["ana","pa"]
Same result. The limit kept the first two pieces and discarded ss.
Configuration lines hit the same wall, because values in KEY=value files often contain = themselves: query strings in connection URLs, and the padding at the end of base64.
for (const line of ['DATABASE_URL=postgres://db.internal/app?sslmode=require', 'API_TOKEN=c2VjcmV0Cg==']) {
console.log(JSON.stringify(line.split('=', 2)))
}
["DATABASE_URL","postgres://db.internal/app?sslmode"]
["API_TOKEN","c2VjcmV0Cg"]
The connection string now ends at ?sslmode, with require gone. The token loses its padding: a strict decoder such as Python’s base64.b64decode rejects it with Incorrect padding, a lenient one such as atob still recovers the bytes, and any code that compares the token as a string fails.
Why it happens
The limit argument is a cap on the length of the returned array, not a cap on the number of splits. MDN says it directly: split “stops when limit entries have been placed in the array. Any leftover text is not included in the array at all.” A limit of 0 returns an empty array.
Other languages chose the other meaning, which is where the expectation comes from:
| Call | Result | Source |
|---|---|---|
JavaScript 'ana:pa:ss'.split(':', 2) | ['ana', 'pa'] | run on Node v26.2.0 |
Python 'ana:pa:ss'.split(':', 1) | ['ana', 'pa:ss'] | run on Python 3.14.5 |
Go strings.SplitN("ana:pa:ss", ":", 2) | ["ana" "pa:ss"] | Go docs: “the last substring will be the unsplit remainder” |
Note the off-by-one as well: Python counts splits (maxsplit=1), while JavaScript and Go count pieces (2). Someone translating a Python line by changing maxsplit to limit gets both the number and the semantics wrong.
For Basic auth the correct behaviour is not a matter of taste. RFC 7617 says “the first colon in a user-pass string separates user-id and password from one another; text after the first colon is part of the password”, and that a user-id containing a colon is invalid. A password containing a colon is valid, so a decoder that drops it is broken.
What to do
Split on the first separator with indexOf. This is the general fix and it is short:
function splitOnce(str, sep) {
const i = str.indexOf(sep)
return i === -1 ? [str] : [str.slice(0, i), str.slice(i + sep.length)]
}
console.log(JSON.stringify(splitOnce('ana:pa:ss', ':')))
console.log(JSON.stringify(splitOnce('API_TOKEN=c2VjcmV0Cg==', '=')))
console.log(JSON.stringify(splitOnce('nocolon', ':')))
["ana","pa:ss"]
["API_TOKEN","c2VjcmV0Cg=="]
["nocolon"]
Returning a one-element array when the separator is missing lets the caller tell “no separator” apart from “empty value”, which matters for Basic auth: a credential with no colon at all is malformed and should be rejected, not treated as a user with an empty password.
Or rejoin the tail. If you prefer destructuring, collect the rest and join it back with the same separator:
const [key, ...rest] = 'API_TOKEN=c2VjcmV0Cg=='.split('=')
console.log(JSON.stringify([key, rest.join('=')]))
["API_TOKEN","c2VjcmV0Cg=="]
This only round-trips when the separator is a fixed string. With a regex separator, join cannot restore what the regex matched.
A regex with a capture group also works, with two catches:
console.log(JSON.stringify('ana:pa:ss'.split(/:(.*)/s)))
console.log(JSON.stringify('ana:pa\nss'.split(/:(.*)/)))
["ana","pa:ss",""]
["ana","pa","\nss"]
The captured remainder lands in the array, followed by an empty string for the end of the input. And without the s flag, . does not match a newline, so a value containing one is split again at the line break. indexOf has neither problem.
For .env content, use a real parser. Node ships one:
import { parseEnv } from 'node:util'
console.log(JSON.stringify(parseEnv('API_TOKEN=c2VjcmV0Cg==')))
{"API_TOKEN":"c2VjcmV0Cg=="}
It also handles quoting and comments, which a hand-rolled split does not. Node’s --env-file flag has a sharper edge of its own, covered in node —env-file kills the process when .env is missing. For query strings, use URLSearchParams rather than splitting on & and =; it decodes values too, including the + that means a space.
Caveats
- The truncation is silent. No exception, no warning, and the result has exactly the length you asked for, so a test that only checks the shape of the result passes.
indexOffinds the first separator. That is right for Basic auth andKEY=value. For a format where the last separator matters, such as ahost:portstring, uselastIndexOf, and handle IPv6 literals without a port ([::1],::1) separately, because there the last colon is inside the address.- Splitting on the first
=is not a full.envparser. Quotes,exportprefixes, comments and multi-line values all need handling, which is whyparseEnvis the better tool for whole files. - Everything above was run on Node v26.2.0 and Python 3.14.5.