The match kicked off at a blistering pace, with Cobus Wiese crossing the line for the Bulls in the second minute after a break by Cameron Hanekom. However, David Kriel missed the conversion.
The Stormers responded in the 11th minute as France Malherbe powered over following an offload from Ruben van Heerden, with Jurie Matthee adding the extras.
The Bulls regained momentum in the 19th minute when Jan-Hendrik Wessels latched onto a Willie le Roux grubber to score, this time converted by Kriel.
A penalty from Kriel extended the Bulls' lead to eight points before Matthee responded with one of his own.
The Stormers then edged ahead with a Salmaan Moerat try in the 36th minute, converted by Matthee. Kriel's penalty just before halftime gave the Bulls an 18-17 advantage.
Harold Vorster's try early in the second half, converted by Kriel, widened the Bulls' lead, but both teams struggled from the tee, with missed penalties from Kriel and Matthee.
A yellow card for Gerhard Steenekamp gave the Stormers an opening, and they capitalised with tries from Evan Roos and Warrick Gelant to take the lead.
Kriel's penalty edged the Bulls back in front before Ruan Vermaak extended the gap.
The Stormers nearly snatched victory with a late Ben-Jason Dixon try, but Clayton Blommetjies missed the crucial conversion, sealing a narrow Bulls win.
The result sees the Bulls improve to seven wins from nine matches, while the Stormers' struggles continue with six losses in ten games.