Bring Your Own Sessions
Descope Flows can run the sign-in for your app while your backend keeps the session system it already has. The user signs in through a flow embedded in your app. Your backend validates the result once and then starts your normal session, such as your own JWT or a cookie.
This page is for teams that want Descope's authentication methods and connectors without rewriting session management. If you want Descope to manage sessions too, start from a quickstart instead.
Why Keep Your Own Sessions
Every API, middleware, and mobile client that reads your session token depends on its format and claims. Replacing your auth provider usually changes that token, so a better sign-in experience turns into a migration of every service at once.
With this pattern, Descope only answers who the user is and how they proved it. Your backend still decides what that means for the session: the token format, its lifetime, logout, and authorization. You can change sign-in methods in the flow editor without touching any code that reads your token.
How It Works
The flow runs inside your app with a Descope client SDK. When the flow finishes, your app sends the Descope session token to one new endpoint on your backend. That endpoint validates the token and starts your session. After that, your APIs never call Descope.
Select any part of the diagram to see what it does.
Embed the Flow
The frontend renders a flow and forwards the Descope session token when the flow succeeds. The example uses the React SDK, and every Descope client SDK returns the same sessionJwt in its success callback.
import { Descope } from '@descope/react-sdk';
export function SignIn() {
return (
<Descope
flowId="sign-up-or-in"
onSuccess={async (e) => {
const res = await fetch('/auth/descope', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ sessionJwt: e.detail.sessionJwt }),
});
if (!res.ok) {
// Show an error instead of navigating without a session
return;
}
window.location.assign('/app');
}}
/>
);
}Build the Exchange Endpoint
The exchange endpoint validates the Descope token with a backend SDK. Then the endpoint finds your user and runs the same session logic your login already uses. Replace findOrCreateUser and the signing step with your own code.
import DescopeClient from '@descope/node-sdk';
import { SignJWT } from 'jose';
const descope = DescopeClient({ projectId: process.env.DESCOPE_PROJECT_ID! });
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
app.post('/auth/descope', async (req, res) => {
let authInfo;
try {
authInfo = await descope.validateSession(req.body.sessionJwt);
} catch {
return res.status(401).json({ error: 'invalid_descope_session' });
}
const user = await findOrCreateUser({ descopeUserId: authInfo.token.sub });
const appToken = await new SignJWT({ sub: user.id, roles: user.roles })
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(secret);
res.cookie('app_session', appToken, { httpOnly: true, secure: true, sameSite: 'lax' });
res.json({ ok: true });
});We recommend that you store the Descope user ID (the sub claim) on your user record, because an email address can change. For existing users, you can match on email the first time they sign in, but only when Descope has verified that email. Some OAuth providers return emails they did not verify, and matching on those can let an attacker take over an account. The unverified OAuth emails page covers the risk.
If your app uses Firebase Authentication, this pattern is the same as Firebase custom token authentication. Your endpoint calls createCustomToken instead of signing its own JWT, and the frontend passes the result to signInWithCustomToken. Pass the user ID as the uid argument, and rename claims that Firebase reserves, such as amr, to names like descope_amr. External Token can also return a Firebase token straight from the flow.
Flows for a Signed-In User
Some flows run for a user who is already signed in to your app. Step-up asks the user to prove their identity again before a sensitive action, such as a payout. Other examples are adding a passkey to an existing account and verifying a new email address before your app saves it.
These flows need a little custom logic, because Descope does not hold the session. The flow has to learn which user is signed in, and your backend has to record the result, for example as a step-up claim on your session.
Select any part of the diagram to see what it does.
Tell the flow who is signed in
The browser cannot prove who is signed in by itself. A form.email value from the browser is easy to change, and your session cookie is httpOnly, so browser code cannot read it. Instead, your backend issues a short-lived ticket for the current user. The ticket uses its own signing key, so a leaked ticket never works as a session. The ticket also names the flow it is for in purpose, so a step-up ticket cannot start a passkey enrollment.
const ticketSecret = new TextEncoder().encode(process.env.STEP_UP_TICKET_SECRET);
app.post('/auth/ticket', requireAppSession, async (req, res) => {
const ticket = await new SignJWT({ sub: req.user.id, purpose: 'step_up' })
.setProtectedHeader({ alg: 'HS256' })
.setExpirationTime('2m')
.sign(ticketSecret);
res.json({ ticket });
});Your app passes the ticket to the flow as a flow input, for example form={{ ticket }}. For step-up, the flow runs these steps in order:
Mark the flow as step-up
Add the Step Up action at the start of the flow.
Validate the ticket
Send {{form.ticket}} to your API with a Generic HTTP Connector.
Your API validates the ticket with the ticket key, checks that purpose matches the flow (for example step_up), and returns the user's email.
Set the login ID
Copy the email from the connector response into form.email with a scriptlet.
Challenge the user
Run the second factor you want, such as a passkey, TOTP, or OTP.
The Descope token that the flow returns has su: true, because the sign-in finished after the Step Up action.
Record the result on your session
A second route validates the Descope token, requires su: true, and makes sure the Descope user matches the user on the current session. Then the route reissues your session with your own claim.
app.post('/auth/step-up', requireAppSession, async (req, res) => {
let authInfo;
try {
authInfo = await descope.validateSession(req.body.sessionJwt);
} catch {
return res.status(401).json({ error: 'invalid_descope_session' });
}
if (authInfo.token.su !== true || authInfo.token.sub !== req.user.descopeUserId) {
return res.status(403).json({ error: 'step_up_required' });
}
const appToken = await new SignJWT({
sub: req.user.id,
roles: req.user.roles,
stepped_up_at: authInfo.token.iat,
})
.setProtectedHeader({ alg: 'HS256' })
.setIssuedAt()
.setExpirationTime('1h')
.sign(secret);
res.cookie('app_session', appToken, { httpOnly: true, secure: true, sameSite: 'lax' });
res.json({ ok: true });
});Your sensitive routes can then require a recent stepped_up_at, such as within the last five minutes. The value comes from the Descope token's iat, so posting the same token again later does not extend the window.
A refreshed Descope token keeps su: true with a new iat until the Step Up Token Timeout ends. As a result, one challenge can count for the timeout plus your window. You can choose the two values so that their sum stays within your limit, for example five minutes each for a ten-minute limit.