A SvelteKit starter template with Duke University SAML SSO authentication built in. Clone it, run the setup script, register with Duke, and you have a fully authenticated app.
This template uses SAML vocabulary throughout. At a minimum, you should know that the Duke login page you see when signing in is the Shibboleth IdP (Duke's Identity Provider), and this application is a Service Provider (SP) that communicates with it. For a full glossary of SAML terms, see the Vocabulary tab on Duke's documentation page.
@node-saml/node-saml - login, logout, SP metadata endpointusers and sessions tables (auto-populated on first login)git clone https://github.com/sam-packer/SvelteKit-Duke-Shibboleth-Template
cd SvelteKit-Duke-Shibboleth-Template
bun install
This command generates your SP certificates that you'll give to Duke, and downloads Duke's IdP signing certificate.
bun run setup
This creates three files in certs/:
| File | What it is | Share with Duke? |
|---|---|---|
sp-key.pem |
Private key for signing SAML requests | No - keep secret |
sp-cert.pem |
Public certificate for your SP | Yes - you'll paste this during registration |
idp-cert.pem |
Duke's IdP signing certificate (downloaded automatically) | N/A - this is Duke's certificate |
These files are .gitignore'd so they won't be committed.
How it works: Instead of cramming PEM content into environment variables, this template reads certificates directly from the
certs/directory. This is cleaner, easier to manage, and works well with Docker volume mounts and deployment secret files. Environment variable fallbacks (SAML_SP_PRIVATE_KEY, etc.) are available if you can't use files.
You need a PostgreSQL database. You can use a local instance, Docker, Supabase, Neon, or any PostgreSQL provider.
# Copy the example env file
cp .env.example .env
# Edit .env and set your DATABASE_URL
# Example: postgresql://postgres:password@localhost:5432/my_application
Push the schema to your database:
bun run db:push
Generate a random string for your session string using this command and fill it in to your secret .env file:
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Edit your .env file:
DATABASE_URL="postgresql://postgres:password@localhost:5432/my_application"
SESSION_SECRET="your_randomly_generated_string"
SAML_SP_ENTITY_ID="https://myapp.duke.edu"
ORIGIN="http://localhost:5173"
These values must be consistent with what you register with Duke. Getting them wrong is the most common source of SAML errors.
| Value | What it is | Example |
|---|---|---|
SAML_SP_ENTITY_ID |
A unique identifier for your app in Duke's IdP. Must match your Duke registration exactly. Always your production domain. | https://myapp.duke.edu |
ORIGIN |
The URL where your app is currently reachable. Changes between environments. Used to construct the ACS callback URL. | http://localhost:5173 or https://myapp.duke.edu |
| ACS URL | Where Duke sends the user after login. Auto-generated as {ORIGIN}/api/auth/callback. |
http://localhost:5173/api/auth/callback |
The Entity ID is always your production domain: Duke only supports one Entity ID per SP registration. However, you can register multiple ACS URLs on the same SP, which is how local development works. You register both your production ACS and a localhost ACS under the same Entity ID.
For local development (.env):
SAML_SP_ENTITY_ID="https://myapp.example.com"
ORIGIN="http://localhost:5173"
For production (.env):
SAML_SP_ENTITY_ID="https://myapp.example.com"
ORIGIN="https://myapp.example.com"
Note:
SAML_SP_ENTITY_IDstays the same in both environments. OnlyORIGINchanges, which changes the ACS callback URL that gets sent in the SAML request. Both ACS URLs must be registered with Duke (see step 5).
Duke provides a self-service portal for SP registration:
https://authentication.oit.duke.edu/manager/register/sp
Select "No, I will provide registration details manually." and fill in the following fields.
Use your production domain. This is a permanent identifier, it does not change between environments.
https://myapp.duke.edu
This must exactly match the SAML_SP_ENTITY_ID in your .env file.
Your NetID should automatically be selected as the owner, you can add a support group if you choose.
Open certs/sp-cert.pem and paste the certificate content without the -----BEGIN CERTIFICATE----- and
-----END CERTIFICATE----- lines. Just the base64 block in the middle.
These control how Duke secures the SAML response it sends back to your app.
certs/sp-key.pem).certs/idp-cert.pem).The template's SAML configuration (src/lib/server/saml.ts) expects a signed response (
wantAuthnResponseSigned: true). Do not check the Sign Assertion box in the Relying Party Settings.
This is where Duke redirects the user after authentication. You can register multiple ACS URLs on the same SP. Add both your production URL and localhost for development.
ACS 1 (production):
2.0:bindings:HTTP-POSThttps://myapp.duke.edu/api/auth/callbackACS 2 (local development):
2.0:bindings:HTTP-POSThttp://localhost:5173/api/auth/callbackThe app sends the correct ACS URL in each SAML request based on the ORIGIN environment variable, so Duke knows which
one to use.
Select the attributes you want Duke to release to your SP. The template is preconfigured to parse all of these:
displayName - Full name (and uses preferred name)eduPersonPrincipalName - NetID@duke.edu (primary identifier)eduPersonScopedAffiliation - Relationships to Duke (student, staff, faculty, etc.)uid - NetIDgivenName - First legal namesn - Last legal namemail - Duke email addressYou can also request additional attributes, however you'll need to modify the database schema to accept and store those
attributes as well. The correct file to do this in is: src/lib/server/db/schema.ts.
bun run dev
Visit http://localhost:5173. You should see the "SvelteKit is running!" page with a button to sign in. Clicking it
will redirect you directly to Duke's Shibboleth IdP, and on success you'll see your name and profile attributes.
├── certs/ # SP and IdP certificates (gitignored)
│ ├── sp-key.pem # Your SP private key
│ ├── sp-cert.pem # Your SP certificate
│ └── idp-cert.pem # Duke's IdP certificate
├── scripts/
│ └── setup.js # Cross-platform cert generation + IdP cert download
├── src/
│ ├── app.d.ts # TypeScript types (App.Locals)
│ ├── app.html # HTML shell
│ ├── hooks.server.ts # Session verification, route protection & cleanup
│ ├── lib/
│ │ ├── components/ui/ # shadcn-svelte components
│ │ ├── utils.ts # Tailwind utilities
│ │ └── server/
│ │ ├── certs.ts # PEM normalization, cert reading & caching
│ │ ├── saml.ts # SAML protocol (login URL, validation, metadata)
│ │ ├── session.ts # Session lifecycle (create, get, delete, cleanup)
│ │ ├── url.ts # URL validation (open redirect protection)
│ │ ├── user.ts # User upsert from SAML profile
│ │ └── db/
│ │ ├── index.ts # Database client (lazy singleton)
│ │ └── schema.ts # Drizzle schema (users & sessions tables)
│ └── routes/
│ ├── +layout.svelte # Root layout
│ ├── +layout.server.ts # Passes user to all pages
│ ├── +page.svelte # Homepage (public, includes login button)
│ └── api/
│ ├── auth/
│ │ ├── login/+server.ts # Redirects to Duke Shibboleth IdP
│ │ ├── callback/+server.ts # Handles SAML response
│ │ ├── logout/+server.ts # Clears session, redirects to homepage
│ │ └── metadata/+server.ts # SP metadata XML
│ └── health/+server.ts # Health check
├── drizzle.config.ts # Drizzle ORM config
├── svelte.config.js # SvelteKit config (CSRF trusted origins)
└── package.json
By default, the homepage (/), /api/auth/*, and /api/health are public. Everything else requires authentication.
Unauthenticated requests to protected routes are redirected straight to /api/auth/login, which initiates the SAML
flow with Duke's IdP.
Design note: This template does not include a dedicated login page. This is a well-known pattern for SSO-integrated applications: don't build an intermediate login page when the IdP is your login page.
To change which routes are public, edit src/hooks.server.ts:
// Option A: Protect everything except specific routes (current default)
const publicRoutes = ['/', '/api/auth', '/api/health'];
const isPublicRoute = publicRoutes.some(
(route) => event.url.pathname === route || event.url.pathname.startsWith(route + '/')
);
if (!event.locals.user && !isPublicRoute) {
redirect(302, '/api/auth/login');
}
// Option B: Only protect specific routes
const protectedRoutes = ['/dashboard', '/admin'];
const isProtectedRoute = protectedRoutes.some(
(route) => event.url.pathname === route || event.url.pathname.startsWith(route + '/')
);
if (!event.locals.user && isProtectedRoute) {
redirect(302, '/api/auth/login');
}
The authenticated user is available in all server-side code:
// In +page.server.ts or +layout.server.ts
export const load: PageServerLoad = async ({ locals }) => {
const user = locals.user;
// user.uid, user.eppn, user.displayName, user.mail, etc.
};
// In +server.ts (API routes)
export const GET: RequestHandler = async ({ locals }) => {
if (!locals.user) return new Response('Unauthorized', { status: 401 });
return json({ user: locals.user });
};
// In form actions
export const actions: Actions = {
default: async ({ locals }) => {
const userId = locals.user?.uid;
}
};
The user object shape (defined as SessionUser in src/lib/server/session.ts):
{
uid: string; // NetID (e.g., "abc123")
eppn: string; // [email protected]
displayName: string; // Full name
givenName: string; // First name
sn: string; // Last name
mail: string; // Email address
affiliation: string; // eduPersonAffiliation / eduPersonScopedAffiliation
nameID: string; // SAML NameID
}
The template includes two tables:
users — automatically populated when someone logs in for the first time. Subsequent logins update the profile
and last_login_at timestamp.sessions — stores active sessions with an expiration timestamp. Each session references a user and includes the
SAML NameID for logout. Expired sessions are automatically cleaned up periodically.To add your own tables, edit src/lib/server/db/schema.ts and run:
bun run db:push # Push changes directly (dev)
bun run db:generate # Generate migration SQL (prod)
bun run db:migrate # Run generated migrations (prod)
For production, use db:generate + db:migrate instead of db:push so you have versioned migration files you can
track in git.
bun run build
bun start
Set these on your hosting provider. All are required.
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string |
SESSION_SECRET |
Random hex string for signing sessions |
SAML_SP_ENTITY_ID |
Your app's entity ID (must match Duke registration) |
ORIGIN |
Your app's public URL |
Option A: File-based (recommended)
Mount your certificate files to the certs/ directory. Works with Docker volumes, Kubernetes secrets, etc.
Option B: Environment variables
Set SAML_SP_PRIVATE_KEY, SAML_SP_CERTIFICATE, and SAML_IDP_CERT as environment variables containing the full PEM
content. The app falls back to these if the files don't exist.
bun run setupcerts/idp-cert.pem is present (run bun run setup to re-download)SAML_SP_ENTITY_ID matches what you registered with Duke exactlyORIGIN matches your actual URL (including https://)svelte.config.js already trusts https://shib.oit.duke.edu - make sure this hasn't been removedSESSION_SECRET is set and consistent across restartsX-Forwarded-Proto header so cookies are set with secure: true
correctlybun run setup to regenerate certificatescerts/sp-cert.pem (without the BEGIN/END
lines)-----BEGIN and end with -----ENDThis template is licensed under MIT.